From 9195cfa7ca8c54965110604a59d27be577fcb717 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 16:53:34 +0200 Subject: [PATCH] Rich data bubble: restore bottom inset during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0` max() in the measure closure, but that branch is gated by `!hasDraft`. During streaming (status hidden, alpha=0) the gate skips the max() and nothing else supplies the inset — boundingSize.height ends up at only `revealedContentSize.height + 2` (= bounds.maxY + closingPad + rim), so descenders of the last revealed line sit cramped against the bubble's bottom edge and the bubble visibly grows by 6pt the moment streaming ends and the status node fades in. Adds an explicit `if hasDraft { boundingSize.height += 6.0 }` after the streamingHeaderOffset application — same 6pt the status max() would contribute, so the streaming bubble's bottom breathing room matches its post-stream height and the grow-pop disappears. The `hadDraft && !hasDraft` finalize pass already gets the inset via the status max() (since `!hasDraft` is true), so it's untouched. The non-streaming path is also untouched. TextBubble doesn't have this issue because it computes a position-based `bottomInset` and adds it to boundingSize.height unconditionally (see ChatMessageTextBubbleContentNode.swift:234-256, :827). RichData absorbed that value into the `+ 6.0` constant inside its `!hasDraft` status max(), which made it disappear during streaming. CLAUDE.md gains a sibling bullet flagging that future refactor-the-constant-into-bottomInset work must remove both ends in the same commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + .../ChatMessageRichDataBubbleContentNode.swift | 14 ++++++++++++++ 2 files changed, 15 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 852c0a2b67..a73f8e41c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,7 @@ The `ChatMessageDateAndStatusNode` mirrors TextBubble's placement, adapted to th - **Trail the last line only when the bottom-most item is text.** `lastTextLineFrameIfLastItemIsText(in:)` (in `InstantPageV2Layout.swift`) returns the last line frame *only* when the bottom-most top-level item (max `maxY`) is a `.text`; otherwise nil, so the date wraps below all content (anchored at `contentSize.height`). For tables/images/etc. the date must not trail text buried above the final item. - **InstantPage draws the baseline at the line frame's `maxY`** (`InstantPageRenderer` draws each line at `lineOrigin.y + lineFrame.height`), so the visible text of a plain line sits ~5pt below `maxY`. A date that **trails** on the line (`statusHeight == 0`) adds `trailingBottomPadding` (5pt) to align with the text; a date that **wraps** onto its own line below (`statusHeight > 0`) sits at the bare `maxY`. The pad is 0 for lines taller than their font line height (an inline animated emoji, ~`pointSize·24/17`, already pushes `maxY` down). `lastTextLineFrameIfLastItemIsText` returns `(frame, trailingBottomPadding)`; the bubble applies the pad only in the trailing case. - **Bubble height leaves ~6pt below the date.** One unified formula for all cases: `boundingSize.height = max(boundingSize.height, statusBottomEdge + 6.0)`, where `statusBottomEdge = statusAnchorY + max(1, statusHeight)`. The `statusAnchorY` in the measure (`continue`) closure must mirror the `statusFrameY (+ streamingHeaderOffset)` in the apply closure exactly, or the date will be clipped/misplaced. 6pt matches TextBubble's bottom bubble inset. +- **`hasDraft` adds the same 6pt at the streaming site.** The status max() above is gated by `!hasDraft`, so during streaming (status hidden, alpha=0) it can't supply the bubble's bottom inset. A separate `boundingSize.height += 6.0` inside `if hasDraft` in the SizeBlock closure does it instead — same 6pt, so the streaming bubble's bottom breathing room matches its post-stream height and there's no 6pt grow-pop when the status node fades in at finalize. The `hadDraft && !hasDraft` finalize pass doesn't need it because `!hasDraft` re-enables the status max(). If you ever refactor the `+6.0` constant out of the status max() into a `bottomInset` (TextBubble's pattern), kill this separate term at the same time — they're two ends of the same invariant. ## Inline custom emoji (RichText.textCustomEmoji) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index d4dc6c9ec1..bfc42cdbe2 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -466,6 +466,20 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode boundingSize.height += streamingHeaderOffset } + if hasDraft { + // The bubble's bottom inset is supplied by the `statusBottomEdge + 6.0` + // max() in the measure closure below — but that branch is gated by + // `!hasDraft`, so during streaming the bubble has only its 1pt bottom rim + // past `revealedContentSize.height` (= bounds.maxY + closingPad). Without + // this, descenders of the last revealed line sit cramped against the + // bubble's bottom edge and the bubble visibly grows by 6pt when streaming + // ends and the status node fades in. 6pt matches the constant inside the + // status max() (which itself tracks `TextBubble`'s `bubbleInsets.bottom`). + // `hadDraft && !hasDraft` (the finalize pass) doesn't need this because + // `!hasDraft` re-enables the status max(), which supplies the inset for it. + boundingSize.height += 6.0 + } + let message = item.message let incoming = isIncoming