mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
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:
parent
471d11df16
commit
1d8931b334
5 changed files with 246 additions and 13 deletions
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue