Skip to content

Commit e233cdb

Browse files
isaacclaude
andcommitted
feat: rich-text send-options preview (long-press Send)
Reconnect ChatSendMessageRichTextPreview to the live ChatInputContent so the long-press-Send options screen renders the message as the InstantPage that will actually be sent. The preview was passing richTextPreview: nil after the RichTextEditor migration replaced the markdown-text send path with the structural ChatInputContent model; the gating now mirrors the real send paths (new message: ChatControllerNode; edit: ChatControllerLoadDisplayNode) via a file-private makeRichTextSendPreview helper. Plain / entity-expressible content keeps the existing flat-text morph. Morph: the plain-text path morphs a flat-text copy of the live field, which can't represent rich structure (headings/lists/tables). The rich path instead captures a pixel snapshot of the live input field (before the screen hard-hides it) and crossfades it into the InstantPageV2View as the bubble settles, falling back to the flat-text crossfade if snapshotView returns nil. Clipping: the extracted page content is clipped to the bubble's inner corner radius (15pt, matching the real rich bubble's image.defaultCornerRadius) within the tail-excluded content rect [1, width-7], so images/tables round to the bubble and stay clear of the outgoing tail. Documented in docs/richtext-composer.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5a8afc1 commit e233cdb

4 files changed

Lines changed: 91 additions & 10 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ The first app-side `ios_unit_test` is `//submodules/TextFormat:TextFormatTests`
4848

4949
## RichTextEditor editor & the `ChatInputContent` composer
5050

51-
A from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`, gated behind the `debugRichText` flag) backs the chat composer, and `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).
51+
A from-scratch WYSIWYG rich-text editor (`submodules/TelegramUI/Components/RichTextEditor`, gated behind the `debugRichText` flag) backs the chat composer, and `ChatInputContent` (a TelegramCore-native value model) replaced `NSAttributedString` as the composer currency. The app-side integration — the model and its load-bearing invariants, composer ↔ editor wiring, the formatting-menu / custom-emoji-mention-date / code-block / inline-media round-trips, rich-message send / edit / pending-display, the long-press-Send send-options preview, and draft persistence (local, cross-device media sync, re-login restore) — lives in [`docs/richtext-composer.md`](docs/richtext-composer.md). Editor internals (the TextKit seam, layout) are the editor's own `submodules/TelegramUI/Components/RichTextEditor/CLAUDE.md`; message **rendering** is [`docs/instantpage-richtext.md`](docs/instantpage-richtext.md).
5252

5353
## Embedded watch app (`Telegram/WatchApp`)
5454

‎docs/richtext-composer.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,31 @@ the fallback** (nil / secret-chat / empty-media / upload-failure). The real path
190190
media chain. **Incoming parse** (`SyncCore_RichTextMessageAttribute.swift`) reconstructs `media` from
191191
`photos`/`documents`.
192192

193+
### Send-options preview (long-press Send)
194+
195+
Long-pressing **Send** opens the send-options context screen (`ChatSendMessageContextScreen`), whose preview
196+
bubble renders the message as it will be sent. For rich content the bubble shows the actual `InstantPage` via
197+
`ChatSendMessageRichTextPreview` (wrapping an `InstantPageV2View` in the outgoing message theme), injected through
198+
the `ChatSendMessageContextScreenRichTextPreview` protocol (mirroring the existing media-preview seam, since
199+
`ChatSendMessageActionUI` cannot dep `InstantPageUI`).
200+
201+
- **Gating mirrors the real send paths**, built in `Chat/ChatMessageDisplaySendMessageOptions.swift` via the
202+
file-private `makeRichTextSendPreview(context:content:mediaPreview:)` (predicate: `mediaPreview == nil &&
203+
!content.isEntityExpressible && !content.isEmpty`). New-message branch feeds `composeInputState.content`
204+
(matching `ChatControllerNode`'s send gate; additionally skipped for `.customChatContents`); edit branch feeds
205+
`editMessage.inputState.content` (matching `ChatControllerLoadDisplayNode`'s edit gate). Plain / entity-expressible
206+
content keeps the flat-text morph. With the legacy composer, content is always flat → entity-expressible → no
207+
preview (so no `debugRichText` check is needed).
208+
- **Morph (`MessageItemView`):** the plain-text path morphs a flat-text copy of the live field into the bubble —
209+
invisible because the copy is pixel-identical. That copy can't represent rich structure (headings/lists/tables),
210+
so the rich path instead captures a **pixel snapshot** of the live input field on the source-state layout (before
211+
the screen hard-hides the field), positions it to overlay the field exactly (top-left at `(textInsets.left, 2.0)`,
212+
matching the screen's `sourceMessageItemFrame` math), and crossfades that snapshot into the `InstantPageV2View` as
213+
the bubble settles. Falls back to the flat-text crossfade if `snapshotView` returns nil.
214+
- **Clipping:** the page content is clipped to the bubble's inner corner radius (15pt, matching the real rich
215+
bubble's `image.defaultCornerRadius`) within the tail-excluded content rect `[1, width − 7]` (same as the text
216+
path), so images/tables round to the bubble and stay clear of the outgoing tail.
217+
193218
---
194219

195220
## 6. Draft persistence

‎submodules/ChatSendMessageActionUI/Sources/MessageItemView.swift‎

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ final class MessageItemView: UIView {
207207
private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
208208

209209
private var richTextPreviewView: UIView?
210+
private var richMorphSnapshotView: UIView?
210211
private var mediaPreviewClippingView: UIView?
211212
private var mediaPreview: ChatSendMessageContextScreenMediaPreview?
212213

@@ -694,12 +695,18 @@ final class MessageItemView: UIView {
694695

695696
let textFrame = CGRect(origin: CGPoint(x: textInsets.left, y: textInsets.top), size: positionedTextSize)
696697

697-
// The outgoing bubble reserves space on the right for its tail (mirrors the
698-
// plain-text path's `backgroundSize.width - 1 - 7`); the left keeps a 1pt border.
699-
// The injected page lays out within this content width, so it never runs into the
700-
// tail and the bubble can't exceed the available container width.
698+
// Rich content (which can include full-bleed images/tables) fills the bubble's internal
699+
// area — a 1pt border on the left and a 7pt reservation on the right that clears the
700+
// outgoing tail — matching the text path's `backgroundSize.width - 1 - 7` content rect.
701+
// The content is then clipped to the bubble's inner corner radius (richContentCornerRadius)
702+
// so images/tables round to the bubble instead of overflowing its square corners. The tail
703+
// is part of the background shape, drawn outside this clipped content area.
701704
let richBubbleLeftInset: CGFloat = 1.0
702705
let richBubbleRightInset: CGFloat = 7.0
706+
// Matches ChatMessageItemLayoutConstants.compact.image.defaultCornerRadius (the radius
707+
// the real rich bubble clips this same InstantPageV2View content to). Hard-coded to
708+
// avoid a ChatMessageItemCommon build dependency for one constant.
709+
let richContentCornerRadius: CGFloat = 15.0
703710

704711
var richContentSize: CGSize?
705712
if let richTextPreview {
@@ -709,11 +716,28 @@ final class MessageItemView: UIView {
709716
self.addSubview(richView)
710717
self.richTextPreviewView = richView
711718
}
719+
// Clip the extracted page content to the bubble's inner radius so images/tables
720+
// are rounded to match the bubble instead of overflowing its square corners.
721+
richView.clipsToBounds = true
722+
richView.layer.cornerRadius = richContentCornerRadius
712723
let richBoundingWidth = max(1.0, maxRichBubbleWidth - richBubbleLeftInset - richBubbleRightInset)
713724
richContentSize = richTextPreview.update(boundingWidth: richBoundingWidth, presentationData: presentationData, transition: transition)
725+
726+
// Morph stand-in: a pixel snapshot of the live input field, captured on the first
727+
// (source-state) layout — before the screen hard-hides the field. The flat text node
728+
// can't represent rich structure (headings/lists/tables), so we crossfade this faithful
729+
// snapshot — not the flat text — into the rich preview. snapshotView can return nil if
730+
// the field isn't rendered; the crossfade falls back to the flat text in that case.
731+
if self.richMorphSnapshotView == nil, let textInputSource, let snapshot = textInputSource.sourceView.snapshotView(afterScreenUpdates: false) {
732+
snapshot.isUserInteractionEnabled = false
733+
self.addSubview(snapshot)
734+
self.richMorphSnapshotView = snapshot
735+
}
714736
} else if let richTextPreviewView = self.richTextPreviewView {
715737
self.richTextPreviewView = nil
716738
richTextPreviewView.removeFromSuperview()
739+
self.richMorphSnapshotView?.removeFromSuperview()
740+
self.richMorphSnapshotView = nil
717741
}
718742

719743
let settledContentSize: CGSize
@@ -749,10 +773,26 @@ final class MessageItemView: UIView {
749773
let isSettled = explicitBackgroundSize == nil
750774
if let richTextPreviewView = self.richTextPreviewView, let richContentSize {
751775
let richAlpha: CGFloat = isSettled ? 1.0 : 0.0
752-
let plainAlpha: CGFloat = isSettled ? 0.0 : 1.0
753776
transition.setAlpha(view: richTextPreviewView, alpha: richAlpha)
754-
transition.setAlpha(view: self.textClippingContainer, alpha: plainAlpha)
755777
transition.setFrame(view: richTextPreviewView, frame: CGRect(origin: CGPoint(x: richBubbleLeftInset, y: 1.0), size: richContentSize))
778+
779+
if let richMorphSnapshotView = self.richMorphSnapshotView {
780+
// The flat text node is meaningless for rich content — keep it hidden and crossfade
781+
// the live-field snapshot instead. In the source state the snapshot is positioned to
782+
// overlay the live input field exactly: the field's top-left sits at
783+
// (textInsets.left, 2.0) within this view, because the screen builds
784+
// sourceMessageItemFrame at (field.minX - sourceMessageTextInsets.left, field.minY - 2.0)
785+
// and passes sourceMessageTextInsets as textInsets. The snapshot's own bounds carry the
786+
// field's size. So when the screen hard-hides the live field, this snapshot already covers
787+
// its pixels and the handoff is invisible; it then fades out as the preview fades in.
788+
self.textClippingContainer.alpha = 0.0
789+
let snapshotAlpha: CGFloat = isSettled ? 0.0 : 1.0
790+
transition.setAlpha(view: richMorphSnapshotView, alpha: snapshotAlpha)
791+
transition.setFrame(view: richMorphSnapshotView, frame: CGRect(origin: CGPoint(x: textInsets.left, y: 2.0), size: richMorphSnapshotView.bounds.size))
792+
} else {
793+
let plainAlpha: CGFloat = isSettled ? 0.0 : 1.0
794+
transition.setAlpha(view: self.textClippingContainer, alpha: plainAlpha)
795+
}
756796
} else {
757797
transition.setAlpha(view: self.textClippingContainer, alpha: 1.0)
758798
}

‎submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,24 @@ import TopMessageReactions
1212
import ReactionSelectionNode
1313
import ChatControllerInteraction
1414
import ChatSendAudioMessageContextPreview
15-
import BrowserUI
1615

1716
extension ChatSendMessageEffect {
1817
convenience init(_ effect: ChatSendMessageActionSheetController.SendParameters.Effect) {
1918
self.init(id: effect.id)
2019
}
2120
}
2221

22+
// Builds the send-options rich preview when the composed/edited content will be sent as a rich
23+
// message — mirrors the real send gates (ChatControllerNode.swift for new messages,
24+
// ChatControllerLoadDisplayNode.swift for edits). Returns nil for plain / entity-expressible
25+
// content, empty content, or when a media preview is already shown (media-preview-wins).
26+
private func makeRichTextSendPreview(context: AccountContext, content: ChatInputContent, mediaPreview: ChatSendMessageContextScreenMediaPreview?) -> ChatSendMessageContextScreenRichTextPreview? {
27+
guard mediaPreview == nil, !content.isEntityExpressible, !content.isEmpty else {
28+
return nil
29+
}
30+
return ChatSendMessageRichTextPreview(context: context, instantPage: instantPage(from: content))
31+
}
32+
2333
func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, node: ASDisplayNode, gesture: ContextGesture) {
2434
guard let peerId = selfController.chatLocation.peerId, let textInputView = selfController.chatDisplayNode.textInputView(), let layout = selfController.validLayout else {
2535
return
@@ -173,7 +183,8 @@ func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, no
173183
},
174184
reactionItems: nil,
175185
availableMessageEffects: nil,
176-
isPremium: hasPremium
186+
isPremium: hasPremium,
187+
richTextPreview: makeRichTextSendPreview(context: selfController.context, content: editMessage.inputState.content, mediaPreview: mediaPreview)
177188
)
178189
selfController.sendMessageActionsController = controller
179190
if layout.isNonExclusive {
@@ -288,7 +299,12 @@ func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, no
288299
reactionItems: (!((textInputView.attributedText?.string ?? "").isEmpty) || mediaPreview != nil) ? effectItems : nil,
289300
availableMessageEffects: availableMessageEffects,
290301
isPremium: hasPremium,
291-
richTextPreview: nil
302+
richTextPreview: {
303+
if case .customChatContents = selfController.presentationInterfaceState.subject {
304+
return nil
305+
}
306+
return makeRichTextSendPreview(context: selfController.context, content: selfController.presentationInterfaceState.interfaceState.composeInputState.content, mediaPreview: mediaPreview)
307+
}()
292308
)
293309
selfController.sendMessageActionsController = controller
294310
if layout.isNonExclusive {

0 commit comments

Comments
 (0)