From ec7e0566a7e04bc2d70e0ee17b0840c87228da00 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 22 Jun 2026 16:42:28 +0400 Subject: [PATCH] Improve rich messages serialization. --- Telegram/SourceFiles/data/data_drafts.cpp | 8 - Telegram/SourceFiles/data/data_drafts.h | 3 +- Telegram/SourceFiles/history/history.cpp | 2 - .../SourceFiles/history/history_widget.cpp | 4 +- .../history_view_compose_controls.cpp | 4 +- .../SourceFiles/iv/editor/iv_editor_box.cpp | 2 +- .../iv/editor/iv_editor_session.cpp | 15 +- .../SourceFiles/iv/editor/iv_editor_state.cpp | 47 +- .../SourceFiles/iv/editor/iv_editor_state.h | 5 +- .../iv/editor/iv_editor_widget.cpp | 112 +++- .../SourceFiles/iv/editor/iv_editor_widget.h | 12 + .../iv/iv_rich_message_serializer.cpp | 541 +++++++++++++++--- Telegram/SourceFiles/iv/iv_rich_page.cpp | 2 +- 13 files changed, 595 insertions(+), 162 deletions(-) diff --git a/Telegram/SourceFiles/data/data_drafts.cpp b/Telegram/SourceFiles/data/data_drafts.cpp index 2799fc465b..3a1a3da973 100644 --- a/Telegram/SourceFiles/data/data_drafts.cpp +++ b/Telegram/SourceFiles/data/data_drafts.cpp @@ -14,7 +14,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_widget.h" #include "history/history_item_components.h" #include "iv/iv_rich_page.h" -#include "iv/iv_rich_message_serializer.h" #include "main/main_session.h" #include "data/data_changes.h" #include "data/data_session.h" @@ -113,12 +112,6 @@ void ApplyPeerCloudDraft( const auto richMessage = draft.vrich_message() ? Iv::ParseRichPage(session, *draft.vrich_message()) : std::shared_ptr(); - const auto richMessageEmpty = richMessage - && (Iv::SerializeInputRichMessage( - session, - *richMessage, - Iv::SerializeInputRichMessageMode::FinalSubmit - ).status == Iv::SerializeInputRichMessageStatus::EmptyContent); const auto textWithTags = richMessage ? TextWithTags() : TextWithTags{ @@ -172,7 +165,6 @@ void ApplyPeerCloudDraft( cloudDraft->date = date; cloudDraft->richMessage = richMessage; cloudDraft->richMessageSummary = Iv::FlattenRichPageSummary(richMessage); - cloudDraft->richMessageEmpty = richMessageEmpty; history->setCloudDraft(std::move(cloudDraft)); history->applyCloudDraft(topicRootId, monoforumPeerId); diff --git a/Telegram/SourceFiles/data/data_drafts.h b/Telegram/SourceFiles/data/data_drafts.h index 4c57288685..151a87ee50 100644 --- a/Telegram/SourceFiles/data/data_drafts.h +++ b/Telegram/SourceFiles/data/data_drafts.h @@ -78,10 +78,9 @@ struct Draft { std::shared_ptr richMessage; TextWithEntities richMessageSummary; mtpRequestId saveRequestId = 0; - bool richMessageEmpty = false; [[nodiscard]] bool hasRichMessage() const { - return richMessage && !richMessageEmpty; + return (richMessage != nullptr); } }; diff --git a/Telegram/SourceFiles/history/history.cpp b/Telegram/SourceFiles/history/history.cpp index baa3359d03..0c6ef93d2b 100644 --- a/Telegram/SourceFiles/history/history.cpp +++ b/Telegram/SourceFiles/history/history.cpp @@ -138,7 +138,6 @@ using UpdateFlag = Data::HistoryUpdate::Flag; from.webpage); result->richMessage = from.richMessage; result->richMessageSummary = from.richMessageSummary; - result->richMessageEmpty = from.richMessageEmpty; return result; } @@ -157,7 +156,6 @@ void CopyDraftForThread( to->webpage = from.webpage; to->richMessage = from.richMessage; to->richMessageSummary = from.richMessageSummary; - to->richMessageEmpty = from.richMessageEmpty; } } // namespace diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index ec68a5b8c8..fb1d04dbda 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -6934,9 +6934,7 @@ void HistoryWidget::updateFieldSize() { [[maybe_unused]] const auto previewHeight = _richDraftPreview->resizeGetHeight( fieldWidth, st::historyComposeField.heightMin, - std::min( - st::historyRichMessagePreviewHeight, - st::historyComposeFieldMaxHeight)); + computeMaxFieldHeight()); moveFieldControls(); } diff --git a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp index 8c337fd2fa..d58e824411 100644 --- a/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp +++ b/Telegram/SourceFiles/history/view/controls/history_view_compose_controls.cpp @@ -3850,9 +3850,7 @@ void ComposeControls::updateControlsGeometry(QSize size) { = _richDraftPreview->resizeGetHeight( fieldWidth, _st.field.heightMin, - std::min( - st::historyRichMessagePreviewHeight, - st::historyComposeFieldMaxHeight)); + st::historyComposeFieldMaxHeight); } const auto newComposeHeight = shouldShowRichDraftPreview() ? _richDraftPreview->height() diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp index f637e08822..20e5d14ac2 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_box.cpp @@ -323,7 +323,7 @@ private: const auto size = QSize( std::max( st::ivEditorWindowMinSize.width(), - (st::defaultMarkdown.pageMaxWidth * 3) / 4 + st::messageMarkdown.pageMaxWidth + padding.left() + padding.right()), st::ivEditorWindowDefaultSize.height()); diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_session.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_session.cpp index 75bfee422f..54e6752ec8 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_session.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_session.cpp @@ -3261,23 +3261,12 @@ std::optional<::Data::Draft> ArticleSession::prepareRichDraftForAutosave() const const auto serialized = SerializeInputRichMessage( _session, *richMessage, - SerializeInputRichMessageMode::FinalSubmit); - if (serialized.status == SerializeInputRichMessageStatus::EmptyContent) { - return ::Data::Draft( - TextWithTags(), - FullReplyTo{ - .topicRootId = topicRootId, - .monoforumPeerId = monoforumPeerId, - }, - SuggestOptions(), - MessageCursor(), - ::Data::WebPageDraft()); - } else if (serialized.status != SerializeInputRichMessageStatus::Success) { + SerializeInputRichMessageMode::Draft); + if (serialized.status == SerializeInputRichMessageStatus::Failed) { return std::nullopt; } draft.richMessage = std::move(richMessage); draft.richMessageSummary = FlattenRichPageSummary(*draft.richMessage); - draft.richMessageEmpty = false; return draft; } diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp index e2e66f5b7f..8cb68e36a5 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_state.cpp @@ -4524,7 +4524,6 @@ std::optional State::normalizeTextOnlyQuoteSurface( }); if (!owner || owner->kind != BlockKind::Quote - || owner->pullquote || !owner->blocks.empty()) { return std::nullopt; } @@ -4544,6 +4543,18 @@ std::optional State::normalizeTextOnlyQuoteForInsertion( return normalizeTextOnlyQuoteSurface(container, false); } +bool State::normalizeTextOnlyContainerForInsertion( + const BlockContainerPath &container, + int *insertAt) { + if (!insertAt || *insertAt < 0) { + return false; + } + (void)normalizeTextOnlyListItemForInsertion(container); + (void)normalizeTextOnlyQuoteForInsertion(container); + const auto blocks = blockContainer(container); + return blocks && *insertAt <= int(blocks->size()); +} + bool State::shouldReplaceActiveTextOnlyBlock( const TextNodeDescriptor &descriptor, const std::vector &blocks) const { @@ -5816,13 +5827,9 @@ State::StructuralSelectionDropResult State::moveStructuralSelectionToDropTarget( &target)) { const auto container = candidate.convertBlockContainerPath( block->container); - const auto blocks = container - ? candidate.blockContainer(*container) - : nullptr; if (!container - || !blocks - || block->insertIndex < 0 - || block->insertIndex > int(blocks->size())) { + || !candidate.blockContainer(*container) + || block->insertIndex < 0) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6018,7 +6025,7 @@ State::StructuralSelectionDropResult State::moveStructuralSelectionToDropTarget( if (!candidate.insertPreparedBlocksAtExplicitPosition( std::move(inserted), blockTarget->container, - blockTarget->insertIndex)) { + &blockTarget->insertIndex)) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6122,17 +6129,18 @@ State::StructuralSelectionDropResult State::moveStructuralSelectionToDropTarget( if (!candidate.insertPreparedBlocksAtExplicitPosition( std::move(insertedBlocks), container, - insertAt)) { + &insertAt)) { return CheckedMutationResult{ .apply = false, .result = result, }; } + auto trailingInsertAt = insertAt + 1; if (!trailingBlocks.empty() && !candidate.insertPreparedBlocksAtExplicitPosition( std::move(trailingBlocks), container, - insertAt + 1)) { + &trailingInsertAt)) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6153,7 +6161,7 @@ State::StructuralSelectionDropResult State::moveStructuralSelectionToDropTarget( || !candidate.insertPreparedBlocksAtExplicitPosition( std::move(insertedBlocks), blockTarget->container, - blockTarget->insertIndex)) { + &blockTarget->insertIndex)) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6331,8 +6339,7 @@ State::TextSelectionDropResult State::moveTextSelectionToDropTarget( : nullptr; if (!block || !destination - || block->insertIndex < 0 - || block->insertIndex > int(destination->size())) { + || block->insertIndex < 0) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6348,10 +6355,11 @@ State::TextSelectionDropResult State::moveTextSelectionToDropTarget( .result = result, }; } + auto insertIndex = block->insertIndex; if (!candidate.insertPreparedBlocksAtExplicitPosition( std::move(blocks), *container, - block->insertIndex)) { + &insertIndex)) { return CheckedMutationResult{ .apply = false, .result = result, @@ -6362,7 +6370,7 @@ State::TextSelectionDropResult State::moveTextSelectionToDropTarget( .kind = LeafKind::BlockText, .block = BlockPath{ .container = *container, - .index = block->insertIndex, + .index = insertIndex, }, }, 0, @@ -6745,14 +6753,17 @@ bool State::insertBlocksAfterActiveUnchecked( bool State::insertPreparedBlocksAtExplicitPosition( std::vector blocks, const BlockContainerPath &container, - int insertAt) { + int *insertAt) { + if (!normalizeTextOnlyContainerForInsertion(container, insertAt)) { + return false; + } auto *destination = blockContainer(container); - if (!destination || insertAt < 0 || insertAt > int(destination->size())) { + if (!destination || *insertAt > int(destination->size())) { return false; } NormalizeInsertedOrderedListMetadata(&blocks); destination->insert( - destination->begin() + insertAt, + destination->begin() + *insertAt, std::make_move_iterator(blocks.begin()), std::make_move_iterator(blocks.end())); return true; diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_state.h b/Telegram/SourceFiles/iv/editor/iv_editor_state.h index eefc5bdca5..612eeb9530 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_state.h +++ b/Telegram/SourceFiles/iv/editor/iv_editor_state.h @@ -628,6 +628,9 @@ private: bool keepEmptyParagraph); [[nodiscard]] std::optional normalizeTextOnlyQuoteForInsertion( const BlockContainerPath &container); + [[nodiscard]] bool normalizeTextOnlyContainerForInsertion( + const BlockContainerPath &container, + int *insertAt); [[nodiscard]] bool shouldReplaceActiveTextOnlyBlock( const TextNodeDescriptor &descriptor, const std::vector &blocks) const; @@ -687,7 +690,7 @@ private: [[nodiscard]] bool insertPreparedBlocksAtExplicitPosition( std::vector blocks, const BlockContainerPath &container, - int insertAt); + int *insertAt); [[nodiscard]] bool insertPreparedListItemsAtExplicitPosition( std::vector items, const BlockPath &path, diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp index 4ce81117a0..f143996da3 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.cpp @@ -59,6 +59,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include #include +#include #include #include #include @@ -973,7 +974,6 @@ void EnableQTextEditLineMetrics(style::Markdown &style) { [[nodiscard]] style::Markdown CreateEditorMarkdownStyle() { auto result = st::messageMarkdown; - result.pageMaxWidth = st::defaultMarkdown.pageMaxWidth; EnableQTextEditLineMetrics(result); return result; } @@ -1967,6 +1967,17 @@ Widget::Widget( }); _article->setMediaBlockHost(this); + _selectScroll.scrolls( + ) | rpl::on_next([=](int delta) { + const auto scroll = selectionScrollArea(); + if (!scroll) { + _selectScroll.cancel(); + return; + } + scroll->scrollToY(scroll->scrollTop() + delta); + updateArticleSelectionDragFromCursor(); + }, lifetime()); + const auto &fieldStyle = inlineFieldStyleFor( Markdown::MarkdownArticleTextLeafStyle()); _activeFieldStyleKey = fieldStyle.key; @@ -4884,17 +4895,14 @@ void Widget::mouseMoveEvent(QMouseEvent *e) { >= QApplication::startDragDistance(); if (!_articleSelectionDrag.dragStarted) { if (!movedFarEnough) { + _selectScroll.cancel(); e->accept(); return; } _articleSelectionDrag.dragStarted = true; } - if (_articleSelectionDrag.operation - == ArticleSelectionOperation::DragSelection) { - updateArticleDropTarget(articlePoint); - } else { - updateArticleSelection(articlePoint, hit, editHit); - } + updateArticleSelectionDragAtArticlePoint(articlePoint, hit, editHit); + updateArticleSelectionAutoScroll(e->pos()); e->accept(); } @@ -4904,6 +4912,7 @@ void Widget::mousePressEvent(QMouseEvent *e) { return; } _trackingPointerPress = true; + _selectScroll.cancel(); _pressedControl = {}; _pressedControlPoint = std::nullopt; auto articlePoint = e->pos() - articleTopLeft(); @@ -8267,8 +8276,79 @@ void Widget::clearArticleDropTarget() { } } +Ui::ScrollArea *Widget::selectionScrollArea() const { + for (auto parent = parentWidget(); parent; parent = parent->parentWidget()) { + if (const auto scroll = dynamic_cast(parent)) { + return scroll; + } + } + return nullptr; +} + +bool Widget::articleSelectionAutoScrollActive() const { + return _articleSelectionDrag.active + && _articleSelectionDrag.dragStarted + && ((_articleSelectionDrag.operation + == ArticleSelectionOperation::GrowSelection) + || (_articleSelectionDrag.operation + == ArticleSelectionOperation::DragSelection)); +} + +void Widget::updateArticleSelectionAutoScroll(QPoint widgetPoint) { + if (!articleSelectionAutoScrollActive() || !selectionScrollArea()) { + _selectScroll.cancel(); + return; + } + _selectScroll.checkDeltaScroll( + widgetPoint, + _visibleRange.top, + _visibleRange.bottom); +} + +void Widget::updateArticleSelectionDragAtArticlePoint( + QPoint articlePoint, + const Markdown::MarkdownArticleHitTestResult &hit, + const PreparedEditHit &editHit) { + if (!_articleSelectionDrag.active + || !_articleSelectionDrag.dragStarted + || !_article) { + return; + } + switch (_articleSelectionDrag.operation) { + case ArticleSelectionOperation::GrowSelection: + updateArticleSelection(articlePoint, hit, editHit); + return; + case ArticleSelectionOperation::DragSelection: + updateArticleDropTarget(articlePoint); + return; + case ArticleSelectionOperation::None: + _selectScroll.cancel(); + return; + } +} + +void Widget::updateArticleSelectionDragAtWidgetPoint(QPoint widgetPoint) { + const auto articlePoint = widgetPoint - articleTopLeft(); + const auto hit = _article->hitTest( + articlePoint, + Ui::Text::StateRequest::Flag::LookupSymbol); + const auto editHit = _article->editHitTest(articlePoint); + updateArticleSelectionDragAtArticlePoint(articlePoint, hit, editHit); +} + +void Widget::updateArticleSelectionDragFromCursor() { + if (!articleSelectionAutoScrollActive()) { + _selectScroll.cancel(); + return; + } + const auto widgetPoint = mapFromGlobal(QCursor::pos()); + updateArticleSelectionDragAtWidgetPoint(widgetPoint); + updateArticleSelectionAutoScroll(widgetPoint); +} + void Widget::finishArticleSelection() { const auto repaint = !_articleSelectionDrag.indicatorRect.isEmpty(); + _selectScroll.cancel(); _articleSelectionDrag = {}; if (repaint) { update(); @@ -8596,8 +8676,8 @@ bool Widget::handleFieldMouseEvent(QEvent *event) { return false; } const auto globalPoint = mouse->globalPos(); - const auto articlePoint = mapFromGlobal(globalPoint) - - articleTopLeft(); + const auto widgetPoint = mapFromGlobal(globalPoint); + const auto articlePoint = widgetPoint - articleTopLeft(); const auto cursor = _field->textCursor(); const auto raw = _field->rawTextEdit(); const auto pressCursor = raw->cursorForPosition( @@ -8607,6 +8687,7 @@ bool Widget::handleFieldMouseEvent(QEvent *event) { && cursor.hasSelection() && (pressCursor.position() >= cursor.selectionStart()) && (pressCursor.position() < cursor.selectionEnd()); + _selectScroll.cancel(); _trackingPointerPress = true; if (pressingCurrentSelection && startSelectionDragFromExistingState( @@ -8649,13 +8730,15 @@ bool Widget::handleFieldMouseEvent(QEvent *event) { } const auto globalPoint = mouse->globalPos(); - const auto articlePoint = mapFromGlobal(globalPoint) - articleTopLeft(); + const auto widgetPoint = mapFromGlobal(globalPoint); + const auto articlePoint = widgetPoint - articleTopLeft(); const auto operation = _articleSelectionDrag.operation; const auto movedFarEnough = (globalPoint - _articleSelectionDrag.globalPressPoint).manhattanLength() >= QApplication::startDragDistance(); if (type == QEvent::MouseMove && !_articleSelectionDrag.dragStarted) { if (!movedFarEnough) { + _selectScroll.cancel(); return false; } _articleSelectionDrag.dragStarted = true; @@ -8700,6 +8783,8 @@ bool Widget::handleFieldMouseEvent(QEvent *event) { clearArticleDropTarget(); finishArticleSelection(); _trackingPointerPress = false; + } else { + _selectScroll.cancel(); } return false; } @@ -8710,11 +8795,8 @@ bool Widget::handleFieldMouseEvent(QEvent *event) { _trackingPointerPress = false; return false; } - if (operation == ArticleSelectionOperation::DragSelection) { - updateArticleDropTarget(articlePoint); - } else { - updateArticleSelection(articlePoint, hit, editHit); - } + updateArticleSelectionDragAtArticlePoint(articlePoint, hit, editHit); + updateArticleSelectionAutoScroll(widgetPoint); if (type == QEvent::MouseButtonRelease) { if (operation == ArticleSelectionOperation::DragSelection) { if (_articleSelectionDrag.dropTarget) { diff --git a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h index 8f11bf2bb9..e3c1b42638 100644 --- a/Telegram/SourceFiles/iv/editor/iv_editor_widget.h +++ b/Telegram/SourceFiles/iv/editor/iv_editor_widget.h @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/markdown/iv_markdown_article.h" #include "ui/style/style_core_types.h" #include "ui/widgets/fields/input_field.h" +#include "ui/dragging_scroll_manager.h" #include "ui/rp_widget.h" #include "rpl/lifetime.h" @@ -39,6 +40,7 @@ class ChatStyle; class ChatTheme; class InputField; class PopupMenu; +class ScrollArea; struct PreparedList; } // namespace Ui @@ -588,6 +590,15 @@ private: void updateArticleDropTarget(QPoint articlePoint); void clearArticleDropTarget(); void finishArticleSelection(); + [[nodiscard]] Ui::ScrollArea *selectionScrollArea() const; + [[nodiscard]] bool articleSelectionAutoScrollActive() const; + void updateArticleSelectionAutoScroll(QPoint widgetPoint); + void updateArticleSelectionDragAtArticlePoint( + QPoint articlePoint, + const Markdown::MarkdownArticleHitTestResult &hit, + const Markdown::PreparedEditHit &editHit); + void updateArticleSelectionDragAtWidgetPoint(QPoint widgetPoint); + void updateArticleSelectionDragFromCursor(); [[nodiscard]] bool applyStructuralSelectionDrop(); [[nodiscard]] bool applyInlineSelectionDrop(); [[nodiscard]] bool handleStructuralSelectionKey(QKeyEvent *e); @@ -717,6 +728,7 @@ private: std::optional _boundarySelectionOrigin; Ui::VisibleRange _visibleRange; ArticleSelectionDrag _articleSelectionDrag; + Ui::DraggingScrollManager _selectScroll; std::optional _horizontalScrollLock; bool _settingField = false; bool _trackingPointerPress = false; diff --git a/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp b/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp index 4fa2c0648e..78d6f147cf 100644 --- a/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp +++ b/Telegram/SourceFiles/iv/iv_rich_message_serializer.cpp @@ -56,15 +56,61 @@ constexpr auto kNoEntityIndex = -1; struct SerializeContext { not_null session; - SerializeInputRichMessageMode mode = SerializeInputRichMessageMode::Draft; base::flat_map photos; base::flat_map documents; base::flat_map users; }; +template +struct FinalSubmitNormalizationResult { + Value value; + bool hasMeaningfulContent = false; +}; + +struct FinalSubmitHybridSurface { + RichText text; + QString anchorId; + std::vector blocks; +}; + +using FinalSubmitNormalizedBlocks + = FinalSubmitNormalizationResult>; +using FinalSubmitNormalizedBlock + = FinalSubmitNormalizationResult>; +using FinalSubmitNormalizedListItem + = FinalSubmitNormalizationResult; +using FinalSubmitNormalizedTableCell + = FinalSubmitNormalizationResult; +using FinalSubmitNormalizedTableRow + = FinalSubmitNormalizationResult>; +using FinalSubmitNormalizedHybridSurface + = FinalSubmitNormalizationResult; + +struct TrimEmptyParagraphEdgesRange { + int from = 0; + int till = 0; +}; + +[[nodiscard]] bool RichTextHasVisibleText(const RichText &text) { + return !StringIsEmpty(text.text.text); +} + +[[nodiscard]] bool RichTextHasAnchorPayload(const RichText &text) { + return !text.anchorId.isEmpty() || !text.anchorIds.empty(); +} + +[[nodiscard]] bool RichTextIsStructurallyEmpty(const RichText &text) { + return StringIsEmpty(text.text.text) && !RichTextHasAnchorPayload(text); +} + +[[nodiscard]] bool ParagraphHasTrimmableEdges(const Block &block) { + return (block.kind == BlockKind::Paragraph) + && block.anchorId.isEmpty() + && RichTextIsStructurallyEmpty(block.text); +} + enum class SerializeBlockState : uchar { Success, - Skipped, Failed, }; @@ -73,10 +119,6 @@ struct SerializeBlockResult { std::optional block; }; -[[nodiscard]] bool IsFinalSerializeMode(const SerializeContext *context) { - return context->mode == SerializeInputRichMessageMode::FinalSubmit; -} - [[nodiscard]] SerializeBlockResult SuccessfulSerializeBlock( MTPPageBlock block) { auto result = SerializeBlockResult(); @@ -85,12 +127,6 @@ struct SerializeBlockResult { return result; } -[[nodiscard]] SerializeBlockResult SkippedSerializeBlock() { - auto result = SerializeBlockResult(); - result.state = SerializeBlockState::Skipped; - return result; -} - [[nodiscard]] SerializeBlockResult FailedSerializeBlock() { return {}; } @@ -159,18 +195,21 @@ struct SerializeBlockResult { : MTP_textAnchor(std::move(text), MTP_string(anchorId)); } +[[nodiscard]] MTPRichText WrapRichTextAnchors( + MTPRichText text, + const RichText &richText, + const QString &anchorId) { + for (auto i = richText.anchorIds.rbegin(); + i != richText.anchorIds.rend(); + ++i) { + text = WrapRichTextAnchor(std::move(text), *i); + } + text = WrapRichTextAnchor(std::move(text), richText.anchorId); + return WrapRichTextAnchor(std::move(text), anchorId); +} + [[nodiscard]] bool HasRichTextContent(const RichText &text) { - return !text.text.empty() || !text.anchorId.isEmpty(); -} - -[[nodiscard]] bool HasVisibleRichTextContent(const RichText &text) { - return !StringIsEmpty(text.text.text); -} - -[[nodiscard]] bool ShouldSkipFinalRichTextContent( - const RichText &text, - const SerializeContext *context) { - return IsFinalSerializeMode(context) && !HasVisibleRichTextContent(text); + return !text.text.empty() || RichTextHasAnchorPayload(text); } [[nodiscard]] PhotoData *ResolvePhotoData( @@ -586,8 +625,7 @@ struct SerializeBlockResult { if (!result) { return std::nullopt; } - *result = WrapRichTextAnchor(std::move(*result), text.anchorId); - *result = WrapRichTextAnchor(std::move(*result), anchorId); + *result = WrapRichTextAnchors(std::move(*result), text, anchorId); return result; } @@ -689,9 +727,6 @@ struct SerializeBlockResult { const RichText &text, const QString &anchorId, SerializeContext *context) { - if (IsFinalSerializeMode(context) && !HasVisibleRichTextContent(text)) { - return true; - } const auto paragraph = SerializeParagraphBlock(text, anchorId, context); if (!paragraph) { return false; @@ -756,14 +791,374 @@ struct SerializeBlockResult { (rowspan != 1 ? MTP_int(rowspan) : MTPint())); } +[[nodiscard]] TrimEmptyParagraphEdgesRange FindTrimEmptyParagraphEdgesRange( + const std::vector &blocks) { + auto result = TrimEmptyParagraphEdgesRange(); + result.till = int(blocks.size()); + while (result.from != result.till + && ParagraphHasTrimmableEdges(blocks[result.from])) { + ++result.from; + } + while (result.till != result.from + && ParagraphHasTrimmableEdges(blocks[result.till - 1])) { + --result.till; + } + return result; +} + +void TrimEmptyParagraphEdges(std::vector *blocks) { + if (!blocks) { + return; + } + const auto range = FindTrimEmptyParagraphEdgesRange(*blocks); + if (range.till < int(blocks->size())) { + blocks->erase(begin(*blocks) + range.till, end(*blocks)); + } + if (range.from > 0) { + blocks->erase(begin(*blocks), begin(*blocks) + range.from); + } +} + +[[nodiscard]] FinalSubmitNormalizedBlocks NormalizeFinalSubmitBlocks( + std::vector blocks, + SerializeContext *context, + bool trimParagraphEdges = true); + +[[nodiscard]] FinalSubmitNormalizedBlock NormalizeFinalSubmitBlock( + Block block, + SerializeContext *context); + +[[nodiscard]] FinalSubmitNormalizedListItem NormalizeFinalSubmitListItem( + RichPage::ListItem item, + SerializeContext *context); + +[[nodiscard]] FinalSubmitNormalizedTableCell NormalizeFinalSubmitTableCell( + TableCell cell); + +[[nodiscard]] FinalSubmitNormalizedTableRow NormalizeFinalSubmitTableRow( + RichPage::TableRow row); + +[[nodiscard]] FinalSubmitNormalizedHybridSurface NormalizeFinalSubmitHybridSurface( + RichText text, + QString anchorId, + std::vector blocks, + SerializeContext *context); + +[[nodiscard]] bool GroupedMediaItemHasMeaningfulContent( + const GroupedMediaItem &item, + SerializeContext *context) { + switch (item.kind) { + case BlockKind::Photo: + return ResolveInputPhoto(context, item.photoId, item.photo).has_value(); + case BlockKind::Video: + return ResolveInputDocument( + context, + item.documentId, + item.document).has_value(); + default: + return false; + } +} + +[[nodiscard]] bool BlockHasOwnMeaningfulContent( + const Block &block, + SerializeContext *context) { + switch (block.kind) { + case BlockKind::Heading: + case BlockKind::Paragraph: + case BlockKind::Footer: + case BlockKind::Thinking: + case BlockKind::Code: + return RichTextHasVisibleText(block.text); + case BlockKind::Quote: + return RichTextHasVisibleText(block.text) + || RichTextHasVisibleText(block.caption); + case BlockKind::Photo: + return ResolveInputPhoto(context, block.photoId, block.photo).has_value(); + case BlockKind::Video: + case BlockKind::Audio: + return ResolveInputDocument( + context, + block.documentId, + block.document).has_value(); + case BlockKind::Math: + return !StringIsEmpty(block.formula); + case BlockKind::Table: + case BlockKind::Details: + return RichTextHasVisibleText(block.text); + case BlockKind::GroupedMedia: + if (block.mediaItems.empty()) { + return false; + } + for (const auto &item : block.mediaItems) { + if (!GroupedMediaItemHasMeaningfulContent(item, context)) { + return false; + } + } + return true; + case BlockKind::Map: + return block.zoom > 0; + case BlockKind::AuthorDate: + return RichTextHasVisibleText(block.text) || block.date != 0; + case BlockKind::Divider: + case BlockKind::Anchor: + case BlockKind::Unsupported: + case BlockKind::List: + case BlockKind::Embed: + case BlockKind::EmbedPost: + case BlockKind::Channel: + case BlockKind::RelatedArticles: + break; + } + return false; +} + +[[nodiscard]] FinalSubmitNormalizedHybridSurface NormalizeFinalSubmitHybridSurface( + RichText text, + QString anchorId, + std::vector blocks, + SerializeContext *context) { + auto result = FinalSubmitNormalizedHybridSurface(); + auto normalizedBlocks = NormalizeFinalSubmitBlocks( + std::move(blocks), + context, + false); + auto combined = std::vector(); + combined.reserve(normalizedBlocks.value.size() + 1); + auto paragraph = Block(); + paragraph.kind = BlockKind::Paragraph; + paragraph.text = std::move(text); + paragraph.anchorId = std::move(anchorId); + combined.push_back(std::move(paragraph)); + for (auto &block : normalizedBlocks.value) { + combined.push_back(std::move(block)); + } + const auto range = FindTrimEmptyParagraphEdgesRange(combined); + if (range.from == 0 && range.till > 0) { + result.value.text = std::move(combined.front().text); + result.value.anchorId = std::move(combined.front().anchorId); + result.hasMeaningfulContent = RichTextHasVisibleText(result.value.text); + } + result.value.blocks.reserve(std::max(range.till - std::max(range.from, 1), 0)); + for (auto index = std::max(range.from, 1); index < range.till; ++index) { + result.value.blocks.push_back(std::move(combined[index])); + } + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalizedBlocks.hasMeaningfulContent; + return result; +} + +[[nodiscard]] FinalSubmitNormalizedTableCell NormalizeFinalSubmitTableCell( + TableCell cell) { + auto result = FinalSubmitNormalizedTableCell(); + result.value = std::move(cell); + result.hasMeaningfulContent = RichTextHasVisibleText(result.value.text); + return result; +} + +[[nodiscard]] FinalSubmitNormalizedTableRow NormalizeFinalSubmitTableRow( + RichPage::TableRow row) { + auto result = FinalSubmitNormalizedTableRow(); + if (row.cells.empty()) { + return result; + } + auto normalizedRow = RichPage::TableRow(); + normalizedRow.cells.reserve(row.cells.size()); + for (auto &cell : row.cells) { + auto normalized = NormalizeFinalSubmitTableCell(std::move(cell)); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalized.hasMeaningfulContent; + normalizedRow.cells.push_back(std::move(normalized.value)); + } + result.value = std::move(normalizedRow); + return result; +} + +[[nodiscard]] FinalSubmitNormalizedListItem NormalizeFinalSubmitListItem( + RichPage::ListItem item, + SerializeContext *context) { + auto result = FinalSubmitNormalizedListItem(); + result.value = std::move(item); + if (result.value.blocks.empty()) { + result.hasMeaningfulContent = RichTextHasVisibleText(result.value.text); + return result; + } + auto surface = NormalizeFinalSubmitHybridSurface( + std::move(result.value.text), + std::move(result.value.anchorId), + std::move(result.value.blocks), + context); + result.value.text = std::move(surface.value.text); + result.value.anchorId = std::move(surface.value.anchorId); + result.value.blocks = std::move(surface.value.blocks); + result.hasMeaningfulContent = surface.hasMeaningfulContent; + return result; +} + +[[nodiscard]] FinalSubmitNormalizedBlock NormalizeFinalSubmitBlock( + Block block, + SerializeContext *context) { + auto result = FinalSubmitNormalizedBlock(); + result.value = std::move(block); + auto &normalized = *result.value; + switch (normalized.kind) { + case BlockKind::Heading: + case BlockKind::Footer: + case BlockKind::Code: + case BlockKind::Thinking: + if (!RichTextHasVisibleText(normalized.text)) { + result.value.reset(); + return result; + } + result.hasMeaningfulContent = true; + return result; + case BlockKind::Math: + if (StringIsEmpty(normalized.formula)) { + result.value.reset(); + return result; + } + result.hasMeaningfulContent = true; + return result; + case BlockKind::Quote: + if (!normalized.blocks.empty() && !normalized.pullquote) { + auto surface = NormalizeFinalSubmitHybridSurface( + std::move(normalized.text), + QString(), + std::move(normalized.blocks), + context); + normalized.text = std::move(surface.value.text); + normalized.blocks = std::move(surface.value.blocks); + result.hasMeaningfulContent = surface.hasMeaningfulContent; + } else if (!normalized.blocks.empty()) { + auto blocks = NormalizeFinalSubmitBlocks( + std::move(normalized.blocks), + context); + normalized.blocks = std::move(blocks.value); + result.hasMeaningfulContent = blocks.hasMeaningfulContent; + } + result.hasMeaningfulContent = result.hasMeaningfulContent + || RichTextHasVisibleText(normalized.text) + || RichTextHasVisibleText(normalized.caption); + if (normalized.blocks.empty() && !result.hasMeaningfulContent) { + result.value.reset(); + } + return result; + case BlockKind::List: { + auto items = std::vector(); + items.reserve(normalized.listItems.size()); + for (auto &item : normalized.listItems) { + auto normalizedItem = NormalizeFinalSubmitListItem( + std::move(item), + context); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalizedItem.hasMeaningfulContent; + items.push_back(std::move(normalizedItem.value)); + } + normalized.listItems = std::move(items); + if (normalized.listItems.empty()) { + result.value.reset(); + } + return result; + } + case BlockKind::Table: { + auto rows = std::vector(); + rows.reserve(normalized.tableRows.size()); + for (auto &row : normalized.tableRows) { + auto normalizedRow = NormalizeFinalSubmitTableRow(std::move(row)); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalizedRow.hasMeaningfulContent; + if (normalizedRow.value) { + rows.push_back(std::move(*normalizedRow.value)); + } + } + normalized.tableRows = std::move(rows); + result.hasMeaningfulContent = result.hasMeaningfulContent + || RichTextHasVisibleText(normalized.text); + if (normalized.tableRows.empty() + && !RichTextHasVisibleText(normalized.text)) { + result.value.reset(); + } + return result; + } + case BlockKind::Details: { + auto blocks = NormalizeFinalSubmitBlocks( + std::move(normalized.blocks), + context); + normalized.blocks = std::move(blocks.value); + result.hasMeaningfulContent = blocks.hasMeaningfulContent + || RichTextHasVisibleText(normalized.text); + if (normalized.blocks.empty() + && !RichTextHasVisibleText(normalized.text)) { + result.value.reset(); + } + return result; + } + default: + break; + } + if (!normalized.blocks.empty()) { + auto blocks = NormalizeFinalSubmitBlocks( + std::move(normalized.blocks), + context); + normalized.blocks = std::move(blocks.value); + result.hasMeaningfulContent = blocks.hasMeaningfulContent; + } + if (!normalized.listItems.empty()) { + auto items = std::vector(); + items.reserve(normalized.listItems.size()); + for (auto &item : normalized.listItems) { + auto normalizedItem = NormalizeFinalSubmitListItem( + std::move(item), + context); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalizedItem.hasMeaningfulContent; + items.push_back(std::move(normalizedItem.value)); + } + normalized.listItems = std::move(items); + } + if (!normalized.tableRows.empty()) { + auto rows = std::vector(); + rows.reserve(normalized.tableRows.size()); + for (auto &row : normalized.tableRows) { + auto normalizedRow = NormalizeFinalSubmitTableRow(std::move(row)); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalizedRow.hasMeaningfulContent; + if (normalizedRow.value) { + rows.push_back(std::move(*normalizedRow.value)); + } + } + normalized.tableRows = std::move(rows); + } + result.hasMeaningfulContent = result.hasMeaningfulContent + || BlockHasOwnMeaningfulContent(normalized, context); + return result; +} + +[[nodiscard]] FinalSubmitNormalizedBlocks NormalizeFinalSubmitBlocks( + std::vector blocks, + SerializeContext *context, + bool trimParagraphEdges) { + auto result = FinalSubmitNormalizedBlocks(); + result.value.reserve(blocks.size()); + for (auto &block : blocks) { + auto normalized = NormalizeFinalSubmitBlock(std::move(block), context); + result.hasMeaningfulContent = result.hasMeaningfulContent + || normalized.hasMeaningfulContent; + if (normalized.value) { + result.value.push_back(std::move(*normalized.value)); + } + } + if (trimParagraphEdges) { + TrimEmptyParagraphEdges(&result.value); + } + return result; +} + [[nodiscard]] SerializeBlockResult SerializeBlock( const Block &block, SerializeContext *context) { switch (block.kind) { case BlockKind::Heading: { - if (ShouldSkipFinalRichTextContent(block.text, context)) { - return SkippedSerializeBlock(); - } const auto text = SerializeRichTextWithAnchor( block.text, block.anchorId, @@ -782,18 +1177,11 @@ struct SerializeBlockResult { return FailedSerializeBlock(); } case BlockKind::Paragraph: - if (IsFinalSerializeMode(context) - && !HasVisibleRichTextContent(block.text)) { - return SkippedSerializeBlock(); - } return FinishSerializeBlock(SerializeParagraphBlock( block.text, block.anchorId, context)); case BlockKind::Footer: { - if (ShouldSkipFinalRichTextContent(block.text, context)) { - return SkippedSerializeBlock(); - } const auto text = SerializeRichTextWithAnchor( block.text, block.anchorId, @@ -814,10 +1202,6 @@ struct SerializeBlockResult { if (!block.blocks.empty()) { return FailedSerializeBlock(); } - if (IsFinalSerializeMode(context) - && !HasVisibleRichTextContent(block.text)) { - return SkippedSerializeBlock(); - } const auto caption = SerializeRichTextWithAnchor( block.caption, QString(), @@ -833,10 +1217,6 @@ struct SerializeBlockResult { : FailedSerializeBlock(); } if (block.blocks.empty()) { - if (IsFinalSerializeMode(context) - && !HasVisibleRichTextContent(block.text)) { - return SkippedSerializeBlock(); - } const auto caption = SerializeRichTextWithAnchor( block.caption, QString(), @@ -852,7 +1232,7 @@ struct SerializeBlockResult { : FailedSerializeBlock(); } auto blocks = QVector(); - if ((HasRichTextContent(block.text) || IsFinalSerializeMode(context)) + if (HasRichTextContent(block.text) && !AppendSerializedParagraphBlock( &blocks, block.text, @@ -865,9 +1245,6 @@ struct SerializeBlockResult { return FailedSerializeBlock(); } blocks += *nested; - if (IsFinalSerializeMode(context) && blocks.isEmpty()) { - return SkippedSerializeBlock(); - } const auto caption = SerializeRichTextWithAnchor( block.caption, block.anchorId, @@ -914,8 +1291,7 @@ struct SerializeBlockResult { } auto blocks = QVector(); if ((HasRichTextContent(item.text) - || !item.anchorId.isEmpty() - || IsFinalSerializeMode(context)) + || !item.anchorId.isEmpty()) && !AppendSerializedParagraphBlock( &blocks, item.text, @@ -928,9 +1304,6 @@ struct SerializeBlockResult { return FailedSerializeBlock(); } blocks += *nested; - if (IsFinalSerializeMode(context) && blocks.isEmpty()) { - continue; - } items.push_back(MTP_pageListOrderedItemBlocks( MTP_flags(itemFlags), item.number.num.has_value() @@ -961,10 +1334,6 @@ struct SerializeBlockResult { if (item.number.type.has_value()) { itemFlags |= ItemFlag::f_type; } - if (IsFinalSerializeMode(context) - && !HasVisibleRichTextContent(item.text)) { - continue; - } const auto text = SerializeRichTextWithAnchor( item.text, item.anchorId, @@ -986,9 +1355,6 @@ struct SerializeBlockResult { : MTPstring())); } } - if (IsFinalSerializeMode(context) && items.isEmpty()) { - return SkippedSerializeBlock(); - } return SuccessfulSerializeBlock(MTP_pageBlockOrderedList( MTP_flags(flags), MTP_vector(std::move(items)), @@ -1013,8 +1379,7 @@ struct SerializeBlockResult { } auto blocks = QVector(); if ((HasRichTextContent(item.text) - || !item.anchorId.isEmpty() - || IsFinalSerializeMode(context)) + || !item.anchorId.isEmpty()) && !AppendSerializedParagraphBlock( &blocks, item.text, @@ -1027,9 +1392,6 @@ struct SerializeBlockResult { return FailedSerializeBlock(); } blocks += *nested; - if (IsFinalSerializeMode(context) && blocks.isEmpty()) { - continue; - } items.push_back(MTP_pageListItemBlocks( MTP_flags(flags), MTP_vector(std::move(blocks)))); @@ -1042,10 +1404,6 @@ struct SerializeBlockResult { if (item.taskState == TaskState::Checked) { flags |= Flag::f_checked; } - if (IsFinalSerializeMode(context) - && !HasVisibleRichTextContent(item.text)) { - continue; - } const auto text = SerializeRichTextWithAnchor( item.text, item.anchorId, @@ -1056,9 +1414,6 @@ struct SerializeBlockResult { items.push_back(MTP_pageListItemText(MTP_flags(flags), *text)); } } - if (IsFinalSerializeMode(context) && items.isEmpty()) { - return SkippedSerializeBlock(); - } return SuccessfulSerializeBlock(MTP_pageBlockList( MTP_vector(std::move(items)))); } @@ -1118,9 +1473,6 @@ struct SerializeBlockResult { : FailedSerializeBlock(); } case BlockKind::Math: - if (IsFinalSerializeMode(context) && StringIsEmpty(block.formula)) { - return SkippedSerializeBlock(); - } return SuccessfulSerializeBlock(MTP_pageBlockMath( MTP_string(block.formula))); case BlockKind::Table: { @@ -1144,15 +1496,9 @@ struct SerializeBlockResult { } cells.push_back(*serialized); } - if (IsFinalSerializeMode(context) && cells.isEmpty()) { - continue; - } rows.push_back(MTP_pageTableRow( MTP_vector(std::move(cells)))); } - if (IsFinalSerializeMode(context) && rows.isEmpty()) { - return SkippedSerializeBlock(); - } const auto title = SerializeRichTextWithAnchor( block.text, block.anchorId, @@ -1169,9 +1515,6 @@ struct SerializeBlockResult { using Flag = MTPDpageBlockDetails::Flag; auto flags = block.open ? Flag::f_open : Flag(); const auto blocks = SerializeBlocks(block.blocks, context); - if (IsFinalSerializeMode(context) && blocks && blocks->isEmpty()) { - return SkippedSerializeBlock(); - } const auto title = SerializeRichTextWithAnchor( block.text, block.anchorId, @@ -1211,9 +1554,6 @@ struct SerializeBlockResult { if (!block.blocks.empty()) { return FailedSerializeBlock(); } - if (ShouldSkipFinalRichTextContent(block.text, context)) { - return SkippedSerializeBlock(); - } const auto text = SerializeRichTextWithAnchor( block.text, block.anchorId, @@ -1224,6 +1564,15 @@ struct SerializeBlockResult { MTP_string(block.language))) : FailedSerializeBlock(); } + case BlockKind::Thinking: { + const auto text = SerializeRichTextWithAnchor( + block.text, + block.anchorId, + context); + return text + ? SuccessfulSerializeBlock(MTP_pageBlockThinking(*text)) + : FailedSerializeBlock(); + } case BlockKind::GroupedMedia: { const auto items = SerializeGroupedMediaItems( block.mediaItems, @@ -1245,7 +1594,6 @@ struct SerializeBlockResult { *caption)); } case BlockKind::Unsupported: - case BlockKind::Thinking: case BlockKind::AuthorDate: case BlockKind::Embed: case BlockKind::EmbedPost: @@ -1267,8 +1615,6 @@ struct SerializeBlockResult { case SerializeBlockState::Success: result.push_back(*serialized.block); break; - case SerializeBlockState::Skipped: - break; case SerializeBlockState::Failed: return std::nullopt; } @@ -1301,13 +1647,18 @@ SerializeInputRichMessageResult SerializeInputRichMessage( const RichPage &page, SerializeInputRichMessageMode mode) { auto context = SerializeContext{ session }; - context.mode = mode; - auto blocks = SerializeBlocks(page.blocks, &context); + auto normalizedBlocks = FinalSubmitNormalizedBlocks(); + const auto *sourceBlocks = &page.blocks; + if (mode == SerializeInputRichMessageMode::FinalSubmit) { + normalizedBlocks = NormalizeFinalSubmitBlocks(page.blocks, &context); + sourceBlocks = &normalizedBlocks.value; + } + auto blocks = SerializeBlocks(*sourceBlocks, &context); if (!blocks) { return FailedSerializeInputRichMessage(); } if (mode == SerializeInputRichMessageMode::FinalSubmit - && blocks->isEmpty()) { + && !normalizedBlocks.hasMeaningfulContent) { return EmptySerializeInputRichMessage(); } auto photos = QVector(); diff --git a/Telegram/SourceFiles/iv/iv_rich_page.cpp b/Telegram/SourceFiles/iv/iv_rich_page.cpp index da69a58e9b..c5b3f95846 100644 --- a/Telegram/SourceFiles/iv/iv_rich_page.cpp +++ b/Telegram/SourceFiles/iv/iv_rich_page.cpp @@ -1692,12 +1692,12 @@ void AppendSummaryBlock(TextWithEntities *result, const Block &block) { case BlockKind::Unsupported: case BlockKind::Divider: case BlockKind::Anchor: - case BlockKind::Thinking: return; case BlockKind::Heading: case BlockKind::Paragraph: case BlockKind::Footer: case BlockKind::Code: + case BlockKind::Thinking: AppendSummaryLine(result, block.text); return; case BlockKind::AuthorDate: {