Render markdown rich-text preview in send-message context screen

When the user long-presses Send in the main chat and the composed text passes the
same rich-markdown gate used at send time (richMarkdownAttributeIfNeeded), the
send-options preview bubble renders the message as rich InstantPage content,
crossfading from the raw text as the menu animates in. Plain text and custom chat
contents / edit previews are unchanged.

ChatSendMessageActionUI cannot depend on InstantPageUI (it closes a dependency
cycle via LocationUI -> AttachmentUI -> ... -> ChatTextInputActionButtonsNode), so
the rich view is built in TelegramUI (ChatSendMessageRichTextPreview, wrapping
InstantPageV2View + the outgoing message theme) and injected into the screen via a
new ChatSendMessageContextScreenRichTextPreview protocol, mirroring the existing
media-preview pattern. The main-chat send call site classifies the compose text
and injects the preview; MessageItemView lays it out within the send-button-bounded
bubble envelope and crossfades between the raw text and the rich layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-29 15:46:16 +02:00
parent 471d11df16
commit 1d8931b334
5 changed files with 246 additions and 13 deletions

View file

@ -90,7 +90,8 @@ public func makeChatSendMessageActionSheetController(
openPremiumPaywall: @escaping (ViewController) -> Void,
reactionItems: [ReactionItem]? = nil,
availableMessageEffects: AvailableMessageEffects? = nil,
isPremium: Bool = false
isPremium: Bool = false,
richTextPreview: ChatSendMessageContextScreenRichTextPreview? = nil
) -> ChatSendMessageActionSheetController {
return ChatSendMessageContextScreen(
initialData: initialData,
@ -111,6 +112,7 @@ public func makeChatSendMessageActionSheetController(
openPremiumPaywall: openPremiumPaywall,
reactionItems: reactionItems,
availableMessageEffects: availableMessageEffects,
isPremium: isPremium
isPremium: isPremium,
richTextPreview: richTextPreview
)
}

View file

@ -49,6 +49,13 @@ public protocol ChatSendMessageContextScreenMediaPreview: AnyObject {
func update(containerSize: CGSize, transition: ComponentTransition) -> CGSize
}
public protocol ChatSendMessageContextScreenRichTextPreview: AnyObject {
var view: UIView { get }
// Lays out the rich content for the given bubble width and theme, returning its content
// size. Called every layout pass; the implementation memoizes internally.
func update(boundingWidth: CGFloat, presentationData: PresentationData, transition: ComponentTransition) -> CGSize
}
final class ChatSendMessageContextScreenComponent: Component {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
@ -71,6 +78,7 @@ final class ChatSendMessageContextScreenComponent: Component {
let reactionItems: [ReactionItem]?
let availableMessageEffects: AvailableMessageEffects?
let isPremium: Bool
let richTextPreview: ChatSendMessageContextScreenRichTextPreview?
init(
initialData: ChatSendMessageContextScreen.InitialData,
@ -91,7 +99,8 @@ final class ChatSendMessageContextScreenComponent: Component {
openPremiumPaywall: @escaping (ViewController) -> Void,
reactionItems: [ReactionItem]?,
availableMessageEffects: AvailableMessageEffects?,
isPremium: Bool
isPremium: Bool,
richTextPreview: ChatSendMessageContextScreenRichTextPreview? = nil
) {
self.initialData = initialData
self.context = context
@ -112,6 +121,7 @@ final class ChatSendMessageContextScreenComponent: Component {
self.reactionItems = reactionItems
self.availableMessageEffects = availableMessageEffects
self.isPremium = isPremium
self.richTextPreview = richTextPreview
}
static func ==(lhs: ChatSendMessageContextScreenComponent, rhs: ChatSendMessageContextScreenComponent) -> Bool {
@ -775,19 +785,35 @@ final class ChatSendMessageContextScreenComponent: Component {
if case .editMessage = component.params {
isEditMessage = true
}
// The bubble's right edge is pinned to the send button (see readyMessageItemFrame).
// Constrain the rich layout to the span between that edge and a fixed left margin
// (independent of where the source text input sits) so it isn't laid out at the
// full container width.
let richSendButtonWidth: CGFloat
if component.sourceSendButton is ContextExtractedContentContainingView {
richSendButtonWidth = sourceSendButtonFrame.width
} else {
richSendButtonWidth = min(sourceSendButtonFrame.width, 40.0)
}
let richBubbleRightEdge = sourceSendButtonFrame.maxX - richSendButtonWidth
let richBubbleLeftMargin: CGFloat = 16.0
let maxRichBubbleWidth = max(1.0, min(messageItemViewContainerSize.width, richBubbleRightEdge - richBubbleLeftMargin))
let messageItemSize = messageItemView.update(
context: component.context,
presentationData: presentationData,
backgroundNode: wallpaperBackgroundNode,
textString: textString,
richTextPreview: component.richTextPreview,
maxRichBubbleWidth: maxRichBubbleWidth,
sourceTextInputView: component.textInputView as? ChatInputTextView,
emojiViewProvider: component.emojiViewProvider,
sourceMediaPreview: mediaPreview,
mediaCaptionIsAbove: self.mediaCaptionIsAbove,
textInsets: messageTextInsets,
explicitBackgroundSize: explicitMessageBackgroundSize,
maxTextWidth: localSourceTextInputViewFrame.width,
maxTextWidth: component.richTextPreview != nil ? maxRichBubbleWidth : localSourceTextInputViewFrame.width,
maxTextHeight: 20000.0,
containerSize: messageItemViewContainerSize,
effect: self.presentationAnimationState.key == .animatedIn ? self.selectedMessageEffect : nil,
@ -1469,10 +1495,11 @@ public class ChatSendMessageContextScreen: ViewControllerComponentContainer, Cha
openPremiumPaywall: @escaping (ViewController) -> Void,
reactionItems: [ReactionItem]?,
availableMessageEffects: AvailableMessageEffects?,
isPremium: Bool
isPremium: Bool,
richTextPreview: ChatSendMessageContextScreenRichTextPreview? = nil
) {
self.context = context
super.init(
context: context,
component: ChatSendMessageContextScreenComponent(
@ -1494,7 +1521,8 @@ public class ChatSendMessageContextScreen: ViewControllerComponentContainer, Cha
openPremiumPaywall: openPremiumPaywall,
reactionItems: reactionItems,
availableMessageEffects: availableMessageEffects,
isPremium: isPremium
isPremium: isPremium,
richTextPreview: richTextPreview
),
navigationBarAppearance: .none,
statusBarStyle: .none,

View file

@ -206,6 +206,7 @@ final class MessageItemView: UIView {
private var customEmojiContainerView: CustomEmojiContainerView?
private var emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?
private var richTextPreviewView: UIView?
private var mediaPreviewClippingView: UIView?
private var mediaPreview: ChatSendMessageContextScreenMediaPreview?
@ -283,6 +284,8 @@ final class MessageItemView: UIView {
presentationData: PresentationData,
backgroundNode: WallpaperBackgroundNode?,
textString: NSAttributedString,
richTextPreview: ChatSendMessageContextScreenRichTextPreview?,
maxRichBubbleWidth: CGFloat,
sourceTextInputView: ChatInputTextView?,
emojiViewProvider: ((ChatTextInputTextCustomEmojiAttribute) -> UIView)?,
sourceMediaPreview: ChatSendMessageContextScreenMediaPreview?,
@ -690,9 +693,38 @@ final class MessageItemView: UIView {
let size = CGSize(width: positionedTextSize.width + textInsets.left + textInsets.right, height: positionedTextSize.height + textInsets.top + textInsets.bottom)
let textFrame = CGRect(origin: CGPoint(x: textInsets.left, y: textInsets.top), size: positionedTextSize)
let backgroundSize = explicitBackgroundSize ?? size
// The outgoing bubble reserves space on the right for its tail (mirrors the
// plain-text path's `backgroundSize.width - 1 - 7`); the left keeps a 1pt border.
// The injected page lays out within this content width, so it never runs into the
// tail and the bubble can't exceed the available container width.
let richBubbleLeftInset: CGFloat = 1.0
let richBubbleRightInset: CGFloat = 7.0
var richContentSize: CGSize?
if let richTextPreview {
let richView = richTextPreview.view
if richView.superview !== self {
richView.removeFromSuperview()
self.addSubview(richView)
self.richTextPreviewView = richView
}
let richBoundingWidth = max(1.0, maxRichBubbleWidth - richBubbleLeftInset - richBubbleRightInset)
richContentSize = richTextPreview.update(boundingWidth: richBoundingWidth, presentationData: presentationData, transition: transition)
} else if let richTextPreviewView = self.richTextPreviewView {
self.richTextPreviewView = nil
richTextPreviewView.removeFromSuperview()
}
let settledContentSize: CGSize
if let richContentSize {
// Height = 1pt top inset + content + 5pt bottom inset (page sits at y = 1).
settledContentSize = CGSize(width: richContentSize.width + richBubbleLeftInset + richBubbleRightInset, height: richContentSize.height + 6.0)
} else {
settledContentSize = size
}
let backgroundSize = explicitBackgroundSize ?? settledContentSize
let previousSize = self.currentSize
self.currentSize = backgroundSize
@ -711,7 +743,20 @@ final class MessageItemView: UIView {
textNode.view.frame = CGRect(origin: CGPoint(x: textFrame.minX + textPositioningInsets.left - textClippingContainerFrame.minX, y: textFrame.minY + textPositioningInsets.top - textClippingContainerFrame.minY), size: CGSize(width: maxTextWidth, height: textHeight))
self.updateTextContents()
// Blend between the raw plain text (source/morph) and the injected rich layout
// (settled). `explicitBackgroundSize != nil` means source-morph; nil means settled.
let isSettled = explicitBackgroundSize == nil
if let richTextPreviewView = self.richTextPreviewView, let richContentSize {
let richAlpha: CGFloat = isSettled ? 1.0 : 0.0
let plainAlpha: CGFloat = isSettled ? 0.0 : 1.0
transition.setAlpha(view: richTextPreviewView, alpha: richAlpha)
transition.setAlpha(view: self.textClippingContainer, alpha: plainAlpha)
transition.setFrame(view: richTextPreviewView, frame: CGRect(origin: CGPoint(x: richBubbleLeftInset, y: 1.0), size: richContentSize))
} else {
transition.setAlpha(view: self.textClippingContainer, alpha: 1.0)
}
if let effectIcon = self.effectIcon, let effectIconSize {
if let effectIconView = effectIcon.view {
var animateIn = false

View file

@ -12,6 +12,7 @@ import TopMessageReactions
import ReactionSelectionNode
import ChatControllerInteraction
import ChatSendAudioMessageContextPreview
import BrowserUI
extension ChatSendMessageEffect {
convenience init(_ effect: ChatSendMessageActionSheetController.SendParameters.Effect) {
@ -211,6 +212,14 @@ func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, no
}
}
var richTextPreview: ChatSendMessageContextScreenRichTextPreview?
if case .customChatContents = selfController.presentationInterfaceState.subject {
} else if mediaPreview == nil,
let plainText = textInputView.attributedText?.string,
let attribute = richMarkdownAttributeIfNeeded(context: selfController.context, text: plainText) {
richTextPreview = ChatSendMessageRichTextPreview(context: selfController.context, instantPage: attribute.instantPage)
}
let controller = makeChatSendMessageActionSheetController(
initialData: initialData,
context: selfController.context,
@ -286,7 +295,8 @@ func chatMessageDisplaySendMessageOptions(selfController: ChatControllerImpl, no
},
reactionItems: (!textInputView.text.isEmpty || mediaPreview != nil) ? effectItems : nil,
availableMessageEffects: availableMessageEffects,
isPremium: hasPremium
isPremium: hasPremium,
richTextPreview: richTextPreview
)
selfController.sendMessageActionsController = controller
if layout.isNonExclusive {

View file

@ -0,0 +1,148 @@
import Foundation
import UIKit
import Display
import TelegramCore
import AccountContext
import TelegramPresentationData
import ComponentFlow
import InstantPageUI
import TelegramUIPreferences
import ChatSendMessageActionUI
final class ChatSendMessageRichTextPreview: ChatSendMessageContextScreenRichTextPreview {
private let context: AccountContext
private let instantPage: InstantPage
private let webpage: TelegramMediaWebpage
private let pageView: InstantPageV2View
private var cachedBoundingWidth: CGFloat?
private var cachedThemeIdentity: ObjectIdentifier?
private var cachedContentSize: CGSize = .zero
var view: UIView {
return self.pageView
}
init(context: AccountContext, instantPage: InstantPage) {
self.context = context
self.instantPage = instantPage
let webpage = TelegramMediaWebpage(webpageId: EngineMedia.Id(namespace: 0, id: 0), content: .Loaded(TelegramMediaWebpageLoadedContent(
url: "",
displayUrl: "",
hash: 0,
type: nil,
websiteName: nil,
title: nil,
text: nil,
embedUrl: nil,
embedType: nil,
embedSize: nil,
duration: nil,
author: nil,
isMediaLargeByDefault: nil,
imageIsVideoCover: false,
image: nil,
file: nil,
story: nil,
attributes: [],
instantPage: instantPage
)))
self.webpage = webpage
let renderContext = InstantPageV2RenderContext(
context: context,
webpage: webpage,
sourceLocation: InstantPageSourceLocation(userLocation: .other, peerType: .channel),
imageReference: { image in
return ImageMediaReference.standalone(media: image)
},
fileReference: { file in
return FileMediaReference.standalone(media: file)
},
present: { _, _ in },
push: { _ in },
openUrl: { _ in },
baseNavigationController: { return nil }
)
self.pageView = InstantPageV2View(renderContext: renderContext)
self.pageView.isUserInteractionEnabled = false
}
func update(boundingWidth: CGFloat, presentationData: PresentationData, transition: ComponentTransition) -> CGSize {
let themeIdentity = ObjectIdentifier(presentationData.theme)
if self.cachedBoundingWidth == boundingWidth, self.cachedThemeIdentity == themeIdentity {
return self.cachedContentSize
}
// Combined with MessageItemView's 1pt left border this yields a 10pt left text inset.
let pageHorizontalInset: CGFloat = 9.0
let isDark = presentationData.theme.overallDarkAppearance
let messageTheme = presentationData.theme.chat.message.outgoing
let mainColor = messageTheme.accentTextColor
let codeBlockBackgroundColor: UIColor
if isDark {
codeBlockBackgroundColor = UIColor(white: 0.0, alpha: 0.25)
} else {
codeBlockBackgroundColor = mainColor.withMultipliedAlpha(0.1)
}
let textCategories = InstantPageTextCategories(
kicker: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
header: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 24.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
subheader: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 19.0, lineSpacingFactor: 0.685), color: messageTheme.primaryTextColor),
paragraph: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 17.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
caption: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
credit: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 13.0, lineSpacingFactor: 1.0), color: messageTheme.secondaryTextColor),
table: InstantPageTextAttributes(font: InstantPageFont(style: .sans, size: 15.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor),
article: InstantPageTextAttributes(font: InstantPageFont(style: .serif, size: 18.0, lineSpacingFactor: 1.0), color: messageTheme.primaryTextColor)
)
let pageTheme = InstantPageTheme(
type: isDark ? .dark : .light,
pageBackgroundColor: .clear,
textCategories: textCategories,
serif: false,
codeBlockBackgroundColor: codeBlockBackgroundColor,
linkColor: messageTheme.linkTextColor,
textHighlightColor: messageTheme.accentTextColor.withMultipliedAlpha(0.1),
linkHighlightColor: messageTheme.linkTextColor.withMultipliedAlpha(0.1),
markerColor: UIColor(rgb: 0xfef3bc),
panelBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
panelHighlightedBackgroundColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
panelPrimaryColor: messageTheme.primaryTextColor,
panelSecondaryColor: messageTheme.secondaryTextColor,
panelAccentColor: messageTheme.accentTextColor,
// Preview is always outgoing, so these match the reference bubble's
// `isDark || !isIncoming` (always-true) branch.
tableBorderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.25),
tableHeaderColor: messageTheme.accentControlColor.withMultipliedAlpha(0.1),
controlColor: messageTheme.accentControlColor,
imageTintColor: nil,
overlayPanelColor: isDark ? UIColor(white: 0.0, alpha: 0.13) : UIColor(white: 1.0, alpha: 0.13)
)
let layout = layoutInstantPageV2(
webpage: self.webpage,
instantPage: self.instantPage,
userLocation: .other,
boundingWidth: boundingWidth,
horizontalInset: pageHorizontalInset,
theme: pageTheme,
strings: presentationData.strings,
dateTimeFormat: presentationData.dateTimeFormat,
cachedMessageSyntaxHighlight: nil,
expandedDetails: [:],
fitToWidth: true
)
self.pageView.update(layout: layout, theme: pageTheme, animation: .None)
// The parent (MessageItemView) owns and sets `pageView`'s frame; `update` only
// rebuilds content and reports the size. Rendering is static (.None) the screen
// drives the size/crossfade transition.
self.cachedBoundingWidth = boundingWidth
self.cachedThemeIdentity = themeIdentity
self.cachedContentSize = layout.contentSize
return layout.contentSize
}
}