import Foundation import UIKit import AsyncDisplayKit import Postbox import Display import TelegramCore import SwiftSignalKit import TelegramPresentationData import AccountContext import LocalizedPeerData import PhotoResources import TelegramStringFormatting import TextFormat import InvisibleInkDustNode import TextNodeWithEntities import AnimationCache import MultiAnimationRenderer import ChatMessageItemCommon import MessageInlineBlockBackgroundView public enum ChatMessageReplyInfoType { case bubble(incoming: Bool) case standalone } private let quoteIcon: UIImage = { return UIImage(bundleImageName: "Chat/Message/ReplyQuoteIcon")!.precomposed().withRenderingMode(.alwaysTemplate) }() private let channelIcon: UIImage = { let sourceImage = UIImage(bundleImageName: "Chat/Input/Accessory Panels/PanelTextChannelIcon")! return generateImage(CGSize(width: sourceImage.size.width + 4.0, height: sourceImage.size.height + 4.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) UIGraphicsPushContext(context) sourceImage.draw(at: CGPoint(x: 2.0, y: 1.0 + UIScreenPixel)) UIGraphicsPopContext() })!.precomposed().withRenderingMode(.alwaysTemplate) }() private func generateGroupIcon() -> UIImage { let sourceImage = UIImage(bundleImageName: "Chat/Input/Accessory Panels/PanelTextGroupIcon")! return generateImage(CGSize(width: sourceImage.size.width, height: sourceImage.size.height + 4.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) UIGraphicsPushContext(context) sourceImage.draw(at: CGPoint(x: 0.0, y: 1.0 - UIScreenPixel)) UIGraphicsPopContext() //context.setFillColor(UIColor.white.cgColor) //context.fill(CGRect(origin: CGPoint(), size: size)) })!.precomposed().withRenderingMode(.alwaysTemplate) } private let groupIcon: UIImage = { return generateGroupIcon() }() public class ChatMessageReplyInfoNode: ASDisplayNode { public final class TransitionReplyPanel { public let titleNode: ASDisplayNode public let textNode: ASDisplayNode public let lineNode: ASDisplayNode public let imageNode: ASDisplayNode public let relativeSourceRect: CGRect public let relativeTargetRect: CGRect public init(titleNode: ASDisplayNode, textNode: ASDisplayNode, lineNode: ASDisplayNode, imageNode: ASDisplayNode, relativeSourceRect: CGRect, relativeTargetRect: CGRect) { self.titleNode = titleNode self.textNode = textNode self.lineNode = lineNode self.imageNode = imageNode self.relativeSourceRect = relativeSourceRect self.relativeTargetRect = relativeTargetRect } } public class Arguments { public let presentationData: ChatPresentationData public let strings: PresentationStrings public let context: AccountContext public let type: ChatMessageReplyInfoType public let message: Message? public let replyForward: QuotedReplyMessageAttribute? public let quote: (quote: EngineMessageReplyQuote, isQuote: Bool)? public let story: StoryId? public let parentMessage: Message public let constrainedSize: CGSize public let animationCache: AnimationCache? public let animationRenderer: MultiAnimationRenderer? public let associatedData: ChatMessageItemAssociatedData public init( presentationData: ChatPresentationData, strings: PresentationStrings, context: AccountContext, type: ChatMessageReplyInfoType, message: Message?, replyForward: QuotedReplyMessageAttribute?, quote: (quote: EngineMessageReplyQuote, isQuote: Bool)?, story: StoryId?, parentMessage: Message, constrainedSize: CGSize, animationCache: AnimationCache?, animationRenderer: MultiAnimationRenderer?, associatedData: ChatMessageItemAssociatedData ) { self.presentationData = presentationData self.strings = strings self.context = context self.type = type self.message = message self.replyForward = replyForward self.quote = quote self.story = story self.parentMessage = parentMessage self.constrainedSize = constrainedSize self.animationCache = animationCache self.animationRenderer = animationRenderer self.associatedData = associatedData } } public var visibility: Bool = false { didSet { if self.visibility != oldValue { self.textNode?.visibilityRect = self.visibility ? CGRect.infinite : nil } } } private let backgroundView: MessageInlineBlockBackgroundView private var quoteIconView: UIImageView? private let contentNode: ASDisplayNode private var titleNode: TextNode? private var textNode: TextNodeWithEntities? private var dustNode: InvisibleInkDustNode? private var imageNode: TransformImageNode? private var previousMediaReference: AnyMediaReference? private var expiredStoryIconView: UIImageView? private var currentProgressDisposable: Disposable? public var isQuoteExpanded: Bool = false override public init() { self.backgroundView = MessageInlineBlockBackgroundView(frame: CGRect()) self.contentNode = ASDisplayNode() self.contentNode.isUserInteractionEnabled = false self.contentNode.displaysAsynchronously = false self.contentNode.contentMode = .left self.contentNode.contentsScale = UIScreenScale super.init() self.addSubnode(self.contentNode) } deinit { self.currentProgressDisposable?.dispose() } public func makeProgress() -> Promise { let progress = Promise() self.currentProgressDisposable?.dispose() self.currentProgressDisposable = (progress.get() |> distinctUntilChanged |> deliverOnMainQueue).start(next: { [weak self] hasProgress in guard let self else { return } self.backgroundView.displayProgress = hasProgress }) return progress } public static func asyncLayout(_ maybeNode: ChatMessageReplyInfoNode?) -> (_ arguments: Arguments) -> (CGSize, (CGSize, Bool, ListViewItemUpdateAnimation) -> ChatMessageReplyInfoNode) { let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode) let textNodeLayout = TextNodeWithEntities.asyncLayout(maybeNode?.textNode) let imageNodeLayout = TransformImageNode.asyncLayout(maybeNode?.imageNode) let previousMediaReference = maybeNode?.previousMediaReference let isQuoteExpanded = maybeNode?.isQuoteExpanded ?? false return { arguments in let fontSize = floor(arguments.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0) let titleFont = Font.semibold(fontSize) let textFont = Font.regular(fontSize) var titleString: NSAttributedString var textString: NSAttributedString let isMedia: Bool let isText: Bool var isExpiredStory: Bool = false var isStory: Bool = false let titleColor: UIColor let mainColor: UIColor let dustColor: UIColor var secondaryColor: UIColor? var tertiaryColor: UIColor? var authorNameColor: UIColor? var dashSecondaryColor: UIColor? var dashTertiaryColor: UIColor? let author = arguments.message?.effectiveAuthor let colors = author?.nameColor.flatMap { arguments.context.peerNameColors.get($0, dark: arguments.presentationData.theme.theme.overallDarkAppearance) } authorNameColor = colors?.main dashSecondaryColor = colors?.secondary dashTertiaryColor = colors?.tertiary switch arguments.type { case let .bubble(incoming): titleColor = incoming ? (authorNameColor ?? arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor) : arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor if incoming { if let authorNameColor { mainColor = authorNameColor secondaryColor = dashSecondaryColor tertiaryColor = dashTertiaryColor } else { mainColor = arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor } } else { mainColor = arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor if dashSecondaryColor != nil { secondaryColor = .clear } if dashTertiaryColor != nil { tertiaryColor = .clear } } dustColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor case .standalone: let serviceColor = serviceMessageColorComponents(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper) titleColor = serviceColor.primaryText if dashSecondaryColor != nil { secondaryColor = .clear } if dashTertiaryColor != nil { tertiaryColor = .clear } mainColor = serviceMessageColorComponents(chatTheme: arguments.presentationData.theme.theme.chat, wallpaper: arguments.presentationData.theme.wallpaper).primaryText dustColor = titleColor } if let message = arguments.message { let author = message.effectiveAuthor let rawTitleString = author.flatMap(EnginePeer.init)?.displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder) ?? arguments.strings.User_DeletedAccount titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) if let forwardInfo = message.forwardInfo { if let author = forwardInfo.author { let rawTitleString = EnginePeer(author).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder) titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } else if let authorSignature = forwardInfo.authorSignature { let rawTitleString = authorSignature titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } } if message.id.peerId != arguments.parentMessage.id.peerId, let peer = message.peers[message.id.peerId], (peer is TelegramChannel || peer is TelegramGroup) { final class RunDelegateData { let ascent: CGFloat let descent: CGFloat let width: CGFloat init(ascent: CGFloat, descent: CGFloat, width: CGFloat) { self.ascent = ascent self.descent = descent self.width = width } } let font = titleFont let runDelegateData = RunDelegateData( ascent: font.ascender, descent: font.descender, width: channelIcon.size.width ) var callbacks = CTRunDelegateCallbacks( version: kCTRunDelegateCurrentVersion, dealloc: { dataRef in Unmanaged.fromOpaque(dataRef).release() }, getAscent: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().ascent }, getDescent: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().descent }, getWidth: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().width } ) if let runDelegate = CTRunDelegateCreate(&callbacks, Unmanaged.passRetained(runDelegateData).toOpaque()) { if let peer = peer as? TelegramChannel, case .broadcast = peer.info { let rawTitleString = NSMutableAttributedString(attributedString: titleString) rawTitleString.insert(NSAttributedString(string: ">", attributes: [ .attachment: channelIcon, .foregroundColor: titleColor, NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String): runDelegate ]), at: 0) titleString = rawTitleString } else { let rawTitleString = NSMutableAttributedString(attributedString: titleString) rawTitleString.append(NSAttributedString(string: "\u{200B}", font: titleFont, textColor: titleColor)) rawTitleString.append(NSAttributedString(string: ">", attributes: [ .attachment: groupIcon, .foregroundColor: titleColor, NSAttributedString.Key(rawValue: kCTRunDelegateAttributeName as String): runDelegate ])) rawTitleString.append(NSAttributedString(string: peer.debugDisplayTitle, font: titleFont, textColor: titleColor)) titleString = rawTitleString } } } let (textStringValue, isMediaValue, isTextValue) = descriptionStringForMessage(contentSettings: arguments.context.currentContentSettings.with { $0 }, message: EngineMessage(message), strings: arguments.strings, nameDisplayOrder: arguments.presentationData.nameDisplayOrder, dateTimeFormat: arguments.presentationData.dateTimeFormat, accountPeerId: arguments.context.account.peerId) textString = textStringValue isMedia = isMediaValue isText = isTextValue } else if let replyForward = arguments.replyForward { if let replyAuthorId = replyForward.peerId, let replyAuthor = arguments.parentMessage.peers[replyAuthorId] { let rawTitleString = EnginePeer(replyAuthor).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder) titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } else { let rawTitleString = replyForward.authorName ?? " " titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } textString = NSAttributedString(string: replyForward.quote?.text ?? arguments.presentationData.strings.VoiceOver_ChatList_Message) if let media = replyForward.quote?.media { if let text = replyForward.quote?.text, !text.isEmpty { isMedia = false } else { if let contentKind = mediaContentKind(EngineMedia(media), message: nil, strings: arguments.strings, nameDisplayOrder: arguments.presentationData.nameDisplayOrder, dateTimeFormat: arguments.presentationData.dateTimeFormat, accountPeerId: arguments.context.account.peerId) { let (string, _) = stringForMediaKind(contentKind, strings: arguments.strings) textString = string } else { textString = NSAttributedString(string: arguments.presentationData.strings.VoiceOver_ChatList_Message) } isMedia = true } } else { isMedia = false } isText = replyForward.quote?.text != nil && replyForward.quote?.text != "" } else if let story = arguments.story { if let authorPeer = arguments.parentMessage.peers[story.peerId] { let rawTitleString = EnginePeer(authorPeer).displayTitle(strings: arguments.strings, displayOrder: arguments.presentationData.nameDisplayOrder) titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } else { let rawTitleString = arguments.strings.User_DeletedAccount titleString = NSAttributedString(string: rawTitleString, font: titleFont, textColor: titleColor) } isText = false var hideStory = false if let peer = arguments.parentMessage.peers[story.peerId] as? TelegramChannel, peer.username == nil, peer.usernames.isEmpty { switch peer.participationStatus { case .member: break case .kicked, .left: hideStory = true } } if let storyItem = arguments.parentMessage.associatedStories[story], storyItem.data.isEmpty { isExpiredStory = true textString = NSAttributedString(string: arguments.strings.Chat_ReplyExpiredStory) isMedia = false } else if hideStory { isExpiredStory = true textString = NSAttributedString(string: arguments.strings.Chat_ReplyStoryPrivateChannel) isMedia = false } else { isStory = true textString = NSAttributedString(string: arguments.strings.Chat_ReplyStory) isMedia = true } } else { titleString = NSAttributedString(string: " ", font: titleFont, textColor: titleColor) textString = NSAttributedString(string: " ") isMedia = true isText = false } let isIncoming = arguments.parentMessage.effectivelyIncoming(arguments.context.account.peerId) let placeholderColor: UIColor = isIncoming ? arguments.presentationData.theme.theme.chat.message.incoming.mediaPlaceholderColor : arguments.presentationData.theme.theme.chat.message.outgoing.mediaPlaceholderColor let textColor: UIColor switch arguments.type { case let .bubble(incoming): if isExpiredStory || isStory { textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.accentTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.accentTextColor } else if isMedia { textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor } else { textColor = incoming ? arguments.presentationData.theme.theme.chat.message.incoming.primaryTextColor : arguments.presentationData.theme.theme.chat.message.outgoing.primaryTextColor } case .standalone: textColor = titleColor } let messageText: NSAttributedString if isText, let message = arguments.message { var text: String var messageEntities: [MessageTextEntity] if let quote = arguments.quote?.quote, !quote.text.isEmpty { text = quote.text messageEntities = quote.entities } else { text = foldLineBreaks(message.text) messageEntities = message.textEntitiesAttribute?.entities ?? [] } if let translateToLanguage = arguments.associatedData.translateToLanguage, !text.isEmpty { for attribute in message.attributes { if let attribute = attribute as? TranslationMessageAttribute, !attribute.text.isEmpty, attribute.toLang == translateToLanguage { text = attribute.text messageEntities = attribute.entities break } } } let entities = messageEntities.filter { entity in if case .Strikethrough = entity.type { return true } else if case .Spoiler = entity.type { return true } else if case .CustomEmoji = entity.type { return true } else { return false } } if entities.count > 0 { messageText = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: message) } else { messageText = NSAttributedString(string: text, font: textFont, textColor: textColor) } } else if isText, let replyForward = arguments.replyForward, let quote = replyForward.quote { let entities = quote.entities.filter { entity in if case .Strikethrough = entity.type { return true } else if case .Spoiler = entity.type { return true } else if case .CustomEmoji = entity.type { return true } else { return false } } if entities.count > 0 { messageText = stringWithAppliedEntities(quote.text, entities: entities, baseColor: textColor, linkColor: textColor, baseFont: textFont, linkFont: textFont, boldFont: textFont, italicFont: textFont, boldItalicFont: textFont, fixedFont: textFont, blockQuoteFont: textFont, underlineLinks: false, message: nil) } else { messageText = NSAttributedString(string: quote.text, font: textFont, textColor: textColor) } } else { messageText = NSAttributedString(string: textString.string, font: textFont, textColor: textColor) } var leftInset: CGFloat = 11.0 let spacing: CGFloat = 2.0 var updatedMediaReference: AnyMediaReference? var imageDimensions: CGSize? var hasRoundImage = false if let message = arguments.message, !message.containsSecretMedia { for media in message.media { if let image = media as? TelegramMediaImage { updatedMediaReference = .message(message: MessageReference(message), media: image) if let representation = largestRepresentationForPhoto(image) { imageDimensions = representation.dimensions.cgSize } break } else if let file = media as? TelegramMediaFile, !file.isVideoSticker { updatedMediaReference = .message(message: MessageReference(message), media: file) if let dimensions = file.dimensions { imageDimensions = dimensions.cgSize } else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker { imageDimensions = representation.dimensions.cgSize } if file.isInstantVideo { hasRoundImage = true } break } } } else if let story = arguments.story, let storyPeer = arguments.parentMessage.peers[story.peerId], let storyItem = arguments.parentMessage.associatedStories[story] { if let itemValue = storyItem.get(Stories.StoredItem.self), case let .item(item) = itemValue, let peerReference = PeerReference(storyPeer) { if let image = item.media as? TelegramMediaImage { updatedMediaReference = .story(peer: peerReference, id: story.id, media: image) if let representation = largestRepresentationForPhoto(image) { imageDimensions = representation.dimensions.cgSize } } else if let file = item.media as? TelegramMediaFile, file.isVideo && !file.isVideoSticker { updatedMediaReference = .story(peer: peerReference, id: story.id, media: file) if let dimensions = file.dimensions { imageDimensions = dimensions.cgSize } else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker { imageDimensions = representation.dimensions.cgSize } } } } else if let replyForward = arguments.replyForward, let media = replyForward.quote?.media { if let image = media as? TelegramMediaImage { updatedMediaReference = .message(message: MessageReference(arguments.parentMessage), media: image) if let representation = largestRepresentationForPhoto(image) { imageDimensions = representation.dimensions.cgSize } } else if let file = media as? TelegramMediaFile, file.isVideo && !file.isVideoSticker { updatedMediaReference = .message(message: MessageReference(arguments.parentMessage), media: file) if let dimensions = file.dimensions { imageDimensions = dimensions.cgSize } else if let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker { imageDimensions = representation.dimensions.cgSize } if file.isInstantVideo { hasRoundImage = true } } } var imageTextInset: CGFloat = 0.0 if let _ = imageDimensions { imageTextInset += floor(arguments.presentationData.fontSize.baseDisplaySize * 32.0 / 17.0) } let maximumTextWidth = max(0.0, arguments.constrainedSize.width - 8.0 - imageTextInset) var contrainedTextSize = CGSize(width: maximumTextWidth, height: arguments.constrainedSize.height) let textInsets = UIEdgeInsets(top: 3.0, left: 0.0, bottom: 3.0, right: 0.0) var additionalTitleWidth: CGFloat = 0.0 var maxTitleNumberOfLines = 1 var maxTextNumberOfLines = 1 var adjustedConstrainedTextSize = contrainedTextSize var textCutout: TextNodeCutout? var textCutoutWidth: CGFloat = 0.0 var isQuote = false if let quote = arguments.quote, quote.isQuote { isQuote = true } else if let replyForward = arguments.replyForward, replyForward.quote != nil, replyForward.isQuote { isQuote = true } if isQuote { additionalTitleWidth += 10.0 maxTitleNumberOfLines = 2 maxTextNumberOfLines = isQuoteExpanded ? 50 : 5 if imageTextInset != 0.0 { adjustedConstrainedTextSize.width += imageTextInset textCutout = TextNodeCutout(topLeft: CGSize(width: imageTextInset + 6.0, height: 10.0)) textCutoutWidth = imageTextInset + 6.0 } } let (titleLayout, titleApply) = titleNodeLayout(TextNodeLayoutArguments(attributedString: titleString, backgroundColor: nil, maximumNumberOfLines: maxTitleNumberOfLines, truncationType: .end, constrainedSize: CGSize(width: contrainedTextSize.width - additionalTitleWidth, height: contrainedTextSize.height), alignment: .natural, cutout: nil, insets: textInsets)) if isExpiredStory || isStory { contrainedTextSize.width -= 26.0 } if titleLayout.numberOfLines > 1 { textCutout = nil } let (textLayout, textApply) = textNodeLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: maxTextNumberOfLines, truncationType: .end, constrainedSize: adjustedConstrainedTextSize, alignment: .natural, lineSpacing: 0.07, cutout: textCutout, insets: textInsets)) let imageSide: CGFloat let titleLineHeight: CGFloat = titleLayout.linesRects().first?.height ?? 12.0 imageSide = titleLineHeight * 2.0 var applyImage: (() -> TransformImageNode)? if let imageDimensions = imageDimensions { let boundingSize = CGSize(width: imageSide, height: imageSide) leftInset += imageSide + 6.0 var radius: CGFloat = 4.0 var imageSize = imageDimensions.aspectFilled(boundingSize) if hasRoundImage { radius = boundingSize.width / 2.0 imageSize.width += 2.0 imageSize.height += 2.0 } if !isExpiredStory { applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: radius), imageSize: imageSize, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets(), emptyColor: placeholderColor)) } } var mediaUpdated = false if let updatedMediaReference = updatedMediaReference, let previousMediaReference = previousMediaReference { mediaUpdated = !updatedMediaReference.media.isEqual(to: previousMediaReference.media) } else if (updatedMediaReference != nil) != (previousMediaReference != nil) { mediaUpdated = true } let hasSpoiler: Bool if let message = arguments.message { hasSpoiler = message.attributes.contains(where: { $0 is MediaSpoilerMessageAttribute }) } else { hasSpoiler = false } var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var mediaUserLocation: MediaResourceUserLocation = .other if let message = arguments.message { mediaUserLocation = .peer(message.id.peerId) } if let updatedMediaReference = updatedMediaReference, mediaUpdated && imageDimensions != nil { if let imageReference = updatedMediaReference.concrete(TelegramMediaImage.self) { updateImageSignal = chatMessagePhotoThumbnail(account: arguments.context.account, userLocation: mediaUserLocation, photoReference: imageReference, blurred: hasSpoiler) } else if let fileReference = updatedMediaReference.concrete(TelegramMediaFile.self) { if fileReference.media.isVideo { updateImageSignal = chatMessageVideoThumbnail(account: arguments.context.account, userLocation: mediaUserLocation, fileReference: fileReference, blurred: hasSpoiler) } else if let iconImageRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) { updateImageSignal = chatWebpageSnippetFile(account: arguments.context.account, userLocation: mediaUserLocation, mediaReference: fileReference.abstract, representation: iconImageRepresentation) } } } var size = CGSize() size.width = max(titleLayout.size.width + additionalTitleWidth - textInsets.left - textInsets.right, textLayout.size.width - textInsets.left - textInsets.right - textCutoutWidth) + leftInset + 6.0 size.height = titleLayout.size.height + textLayout.size.height - 2 * (textInsets.top + textInsets.bottom) + 2 * spacing size.height += 2.0 if isExpiredStory || isStory { size.width += 16.0 } return (size, { realSize, attemptSynchronous, animation in let node: ChatMessageReplyInfoNode if let maybeNode = maybeNode { node = maybeNode } else { node = ChatMessageReplyInfoNode() } node.previousMediaReference = updatedMediaReference node.titleNode?.displaysAsynchronously = !arguments.presentationData.isPreview //node.textNode?.textNode.displaysAsynchronously = !arguments.presentationData.isPreview let titleNode = titleApply() var textArguments: TextNodeWithEntities.Arguments? if let cache = arguments.animationCache, let renderer = arguments.animationRenderer { textArguments = TextNodeWithEntities.Arguments(context: arguments.context, cache: cache, renderer: renderer, placeholderColor: placeholderColor, attemptSynchronous: attemptSynchronous) } let previousTextContents = node.textNode?.textNode.layer.contents let textNode = textApply(textArguments) textNode.visibilityRect = node.visibility ? CGRect.infinite : nil if node.titleNode == nil { titleNode.isUserInteractionEnabled = false node.titleNode = titleNode node.contentNode.addSubnode(titleNode) } if node.textNode == nil { textNode.textNode.isUserInteractionEnabled = false textNode.textNode.contentMode = .topLeft textNode.textNode.clipsToBounds = true textNode.textNode.contentsScale = UIScreenScale textNode.textNode.displaysAsynchronously = false node.textNode = textNode node.contentNode.addSubnode(textNode.textNode) } if let applyImage = applyImage { let imageNode = applyImage() if node.imageNode == nil { imageNode.isLayerBacked = false node.addSubnode(imageNode) node.imageNode = imageNode } imageNode.frame = CGRect(origin: CGPoint(x: 9.0, y: 4.0), size: CGSize(width: imageSide, height: imageSide)) if let updateImageSignal = updateImageSignal { imageNode.setSignal(updateImageSignal) } } else if let imageNode = node.imageNode { imageNode.removeFromSupernode() node.imageNode = nil } if let message = arguments.message { node.imageNode?.captureProtected = message.isCopyProtected() } titleNode.frame = CGRect(origin: CGPoint(x: leftInset - textInsets.left - 2.0, y: spacing - textInsets.top + 1.0), size: titleLayout.size) let textFrame = CGRect(origin: CGPoint(x: leftInset - textInsets.left - 2.0 - textCutoutWidth, y: titleNode.frame.maxY - textInsets.bottom + spacing - textInsets.top - 2.0), size: textLayout.size) let effectiveTextFrame = textFrame.offsetBy(dx: (isExpiredStory || isStory) ? 18.0 : 0.0, dy: 0.0) if textNode.textNode.bounds.isEmpty || !animation.isAnimated || textNode.textNode.bounds.height == effectiveTextFrame.height { textNode.textNode.frame = effectiveTextFrame } else { if textNode.textNode.bounds.height != effectiveTextFrame.height { animation.animator.updateFrame(layer: textNode.textNode.layer, frame: effectiveTextFrame, completion: nil) textNode.textNode.layer.setNeedsDisplay() textNode.textNode.layer.display() } else { animation.animator.updateFrame(layer: textNode.textNode.layer, frame: effectiveTextFrame, completion: nil) } if let previousTextContents, let updatedContents = textNode.textNode.contents { let previousTextContents = previousTextContents as AnyObject let updatedContents = updatedContents as AnyObject if previousTextContents !== updatedContents { textNode.textNode.layer.animate(from: previousTextContents, to: updatedContents, keyPath: "contents", timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, duration: 0.2) } } } if isExpiredStory || isStory { let expiredStoryIconView: UIImageView if let current = node.expiredStoryIconView { expiredStoryIconView = current } else { expiredStoryIconView = UIImageView() node.expiredStoryIconView = expiredStoryIconView node.view.addSubview(expiredStoryIconView) } let imageType: ChatExpiredStoryIndicatorType switch arguments.type { case .standalone: imageType = .free case let .bubble(incoming): imageType = incoming ? .incoming : .outgoing } if isExpiredStory { expiredStoryIconView.image = PresentationResourcesChat.chatExpiredStoryIndicatorIcon(arguments.presentationData.theme.theme, type: imageType) } else { expiredStoryIconView.image = PresentationResourcesChat.chatReplyStoryIndicatorIcon(arguments.presentationData.theme.theme, type: imageType) } if let image = expiredStoryIconView.image { let imageSize: CGSize if isExpiredStory { imageSize = CGSize(width: floor(image.size.width * 1.22), height: floor(image.size.height * 1.22)) expiredStoryIconView.frame = CGRect(origin: CGPoint(x: textFrame.minX - 2.0, y: textFrame.minY + 2.0), size: imageSize) } else { imageSize = image.size expiredStoryIconView.frame = CGRect(origin: CGPoint(x: textFrame.minX - 1.0, y: textFrame.minY + 3.0 + UIScreenPixel), size: imageSize) } } } else if let expiredStoryIconView = node.expiredStoryIconView { expiredStoryIconView.removeFromSuperview() } if !textLayout.spoilers.isEmpty { let dustNode: InvisibleInkDustNode if let current = node.dustNode { dustNode = current } else { dustNode = InvisibleInkDustNode(textNode: nil, enableAnimations: arguments.context.sharedContext.energyUsageSettings.fullTranslucency) dustNode.isUserInteractionEnabled = false node.dustNode = dustNode node.contentNode.insertSubnode(dustNode, aboveSubnode: textNode.textNode) } dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) dustNode.update(size: dustNode.frame.size, color: dustColor, textColor: dustColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) } else if let dustNode = node.dustNode { dustNode.removeFromSupernode() node.dustNode = nil } if node.backgroundView.superview == nil { node.contentNode.view.insertSubview(node.backgroundView, at: 0) } let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: realSize.width, height: realSize.height)) node.backgroundView.frame = backgroundFrame var pattern: MessageInlineBlockBackgroundView.Pattern? if let backgroundEmojiId = author?.backgroundEmojiId { pattern = MessageInlineBlockBackgroundView.Pattern( context: arguments.context, fileId: backgroundEmojiId, file: arguments.parentMessage.associatedMedia[MediaId( namespace: Namespaces.Media.CloudFile, id: backgroundEmojiId )] as? TelegramMediaFile ) } var isTransparent: Bool = false if case .standalone = arguments.type { isTransparent = true } node.backgroundView.update( size: backgroundFrame.size, isTransparent: isTransparent, primaryColor: mainColor, secondaryColor: secondaryColor, thirdColor: tertiaryColor, backgroundColor: nil, pattern: pattern, animation: animation ) if isQuote { let quoteIconFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX - 4.0 - quoteIcon.size.width, y: backgroundFrame.minY + 4.0), size: quoteIcon.size) let quoteIconView: UIImageView if let current = node.quoteIconView { quoteIconView = current animation.animator.updateFrame(layer: quoteIconView.layer, frame: quoteIconFrame, completion: nil) } else { quoteIconView = UIImageView(image: quoteIcon) node.quoteIconView = quoteIconView node.contentNode.view.addSubview(quoteIconView) quoteIconView.frame = quoteIconFrame } quoteIconView.tintColor = mainColor } else { if let quoteIconView = node.quoteIconView { node.quoteIconView = nil quoteIconView.removeFromSuperview() } } node.contentNode.frame = CGRect(origin: CGPoint(), size: size) return node }) } } public func updateTouchesAtPoint(_ point: CGPoint?) { var isHighlighted = false if point != nil { isHighlighted = true } let transition: ContainedViewLayoutTransition = .animated(duration: isHighlighted ? 0.3 : 0.2, curve: .easeInOut) let scale: CGFloat = isHighlighted ? ((self.bounds.width - 5.0) / self.bounds.width) : 1.0 transition.updateSublayerTransformScale(node: self, scale: scale, beginWithCurrentState: true) } public func animateFromInputPanel(sourceReplyPanel: TransitionReplyPanel, unclippedTransitionNode: ASDisplayNode? = nil, localRect: CGRect, transition: CombinedTransition) -> CGPoint { let sourceParentNode = ASDisplayNode() let sourceParentOffset: CGPoint if let unclippedTransitionNode = unclippedTransitionNode { unclippedTransitionNode.addSubnode(sourceParentNode) sourceParentNode.frame = sourceReplyPanel.relativeSourceRect sourceParentOffset = self.view.convert(CGPoint(), to: sourceParentNode.view) sourceParentNode.clipsToBounds = true let panelOffset = sourceReplyPanel.relativeTargetRect.minY - sourceReplyPanel.relativeSourceRect.minY sourceParentNode.frame = sourceParentNode.frame.offsetBy(dx: 0.0, dy: panelOffset) sourceParentNode.bounds = sourceParentNode.bounds.offsetBy(dx: 0.0, dy: panelOffset) transition.vertical.animatePositionAdditive(layer: sourceParentNode.layer, offset: CGPoint(x: 0.0, y: -panelOffset)) transition.vertical.animateOffsetAdditive(layer: sourceParentNode.layer, offset: -panelOffset) } else { self.addSubnode(sourceParentNode) sourceParentOffset = CGPoint() } sourceParentNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak sourceParentNode] _ in sourceParentNode?.removeFromSupernode() }) if let titleNode = self.titleNode { let offset = CGPoint( x: localRect.minX + sourceReplyPanel.titleNode.frame.minX - titleNode.frame.minX, y: localRect.minY + sourceReplyPanel.titleNode.frame.midY - titleNode.frame.midY ) transition.horizontal.animatePositionAdditive(node: titleNode, offset: CGPoint(x: offset.x, y: 0.0)) transition.vertical.animatePositionAdditive(node: titleNode, offset: CGPoint(x: 0.0, y: offset.y)) sourceParentNode.addSubnode(sourceReplyPanel.titleNode) titleNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) sourceReplyPanel.titleNode.frame = sourceReplyPanel.titleNode.frame .offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y) .offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y) transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.titleNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false) transition.vertical.animatePositionAdditive(node: sourceReplyPanel.titleNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false) } if let textNode = self.textNode { let offset = CGPoint( x: localRect.minX + sourceReplyPanel.textNode.frame.minX - textNode.textNode.frame.minX, y: localRect.minY + sourceReplyPanel.textNode.frame.midY - textNode.textNode.frame.midY ) transition.horizontal.animatePositionAdditive(node: textNode.textNode, offset: CGPoint(x: offset.x, y: 0.0)) transition.vertical.animatePositionAdditive(node: textNode.textNode, offset: CGPoint(x: 0.0, y: offset.y)) sourceParentNode.addSubnode(sourceReplyPanel.textNode) textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) sourceReplyPanel.textNode.frame = sourceReplyPanel.textNode.frame .offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y) .offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y) transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.textNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false) transition.vertical.animatePositionAdditive(node: sourceReplyPanel.textNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false) } if let imageNode = self.imageNode { let offset = CGPoint( x: localRect.minX + sourceReplyPanel.imageNode.frame.midX - imageNode.frame.midX, y: localRect.minY + sourceReplyPanel.imageNode.frame.midY - imageNode.frame.midY ) transition.horizontal.animatePositionAdditive(node: imageNode, offset: CGPoint(x: offset.x, y: 0.0)) transition.vertical.animatePositionAdditive(node: imageNode, offset: CGPoint(x: 0.0, y: offset.y)) sourceParentNode.addSubnode(sourceReplyPanel.imageNode) imageNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) sourceReplyPanel.imageNode.frame = sourceReplyPanel.imageNode.frame .offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y) .offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y) transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.imageNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false) transition.vertical.animatePositionAdditive(node: sourceReplyPanel.imageNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false) } do { let backgroundView = self.backgroundView let offset = CGPoint( x: localRect.minX + sourceReplyPanel.lineNode.frame.minX - backgroundView.frame.minX, y: localRect.minY + sourceReplyPanel.lineNode.frame.minY - backgroundView.frame.minY ) transition.horizontal.animatePositionAdditive(layer: backgroundView.layer, offset: CGPoint(x: offset.x, y: 0.0)) transition.vertical.animatePositionAdditive(layer: backgroundView.layer, offset: CGPoint(x: 0.0, y: offset.y)) sourceParentNode.addSubnode(sourceReplyPanel.lineNode) backgroundView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1) sourceReplyPanel.lineNode.frame = sourceReplyPanel.lineNode.frame .offsetBy(dx: sourceParentOffset.x, dy: sourceParentOffset.y) .offsetBy(dx: localRect.minX - offset.x, dy: localRect.minY - offset.y) transition.horizontal.animatePositionAdditive(node: sourceReplyPanel.lineNode, offset: CGPoint(x: offset.x, y: 0.0), removeOnCompletion: false) transition.vertical.animatePositionAdditive(node: sourceReplyPanel.lineNode, offset: CGPoint(x: 0.0, y: offset.y), removeOnCompletion: false) return offset } } public func mediaTransitionView() -> UIView? { if let imageNode = self.imageNode { return imageNode.view } return nil } }