diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift index fcb8304aab..fb8d70f0d8 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageActionSheetController.swift @@ -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 ) } diff --git a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift index 81b7efae46..ceddd73c00 100644 --- a/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift +++ b/submodules/ChatSendMessageActionUI/Sources/ChatSendMessageContextScreen.swift @@ -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, diff --git a/submodules/ChatSendMessageActionUI/Sources/MessageItemView.swift b/submodules/ChatSendMessageActionUI/Sources/MessageItemView.swift index 0dee40ddfd..7e4ab90dbe 100644 --- a/submodules/ChatSendMessageActionUI/Sources/MessageItemView.swift +++ b/submodules/ChatSendMessageActionUI/Sources/MessageItemView.swift @@ -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 diff --git a/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift b/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift index b589ed3100..c38fca6b58 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatMessageDisplaySendMessageOptions.swift @@ -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 { diff --git a/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift new file mode 100644 index 0000000000..8afffb287b --- /dev/null +++ b/submodules/TelegramUI/Sources/Chat/ChatSendMessageRichTextPreview.swift @@ -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 + } +}