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