import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import AccountContext import StickerResources import PhotoResources import TelegramStringFormatting import AnimatedCountLabelNode import AnimatedNavigationStripeNode import ContextUI import RadialStatusNode import TextFormat import ChatPresentationInterfaceState import TextNodeWithEntities import AnimationCache import MultiAnimationRenderer import TranslateUI import ChatControllerInteraction private enum PinnedMessageAnimation { case slideToTop case slideToBottom } final class ChatAdPanelNode: ASDisplayNode { private let context: AccountContext private(set) var message: Message? var controllerInteraction: ChatControllerInteraction? private let tapButton: HighlightTrackingButtonNode private let contextContainer: ContextControllerSourceNode private let clippingContainer: ASDisplayNode private let contentContainer: ASDisplayNode private let contentTextContainer: ASDisplayNode private let adNode: TextNode private let titleNode: TextNode private let textNode: TextNodeWithEntities private let removeButtonNode: HighlightTrackingButtonNode private let removeBackgroundNode: ASImageNode private let removeTextNode: ImmediateTextNode private let closeButton: HighlightableButtonNode private let imageNode: TransformImageNode private let imageNodeContainer: ASDisplayNode private let separatorNode: ASDisplayNode private var currentLayout: (CGFloat, CGFloat, CGFloat)? private var currentMessage: Message? private var previousMediaReference: AnyMediaReference? private let fetchDisposable = MetaDisposable() private let animationCache: AnimationCache? private let animationRenderer: MultiAnimationRenderer? init(context: AccountContext, animationCache: AnimationCache?, animationRenderer: MultiAnimationRenderer?) { self.context = context self.animationCache = animationCache self.animationRenderer = animationRenderer self.tapButton = HighlightTrackingButtonNode() self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true self.contextContainer = ContextControllerSourceNode() self.clippingContainer = ASDisplayNode() self.clippingContainer.clipsToBounds = true self.contentContainer = ASDisplayNode() self.contentTextContainer = ASDisplayNode() self.adNode = TextNode() self.adNode.displaysAsynchronously = false self.adNode.isUserInteractionEnabled = false self.removeButtonNode = HighlightTrackingButtonNode() self.removeBackgroundNode = ASImageNode() self.removeTextNode = ImmediateTextNode() self.removeTextNode.displaysAsynchronously = false self.removeTextNode.isUserInteractionEnabled = false self.titleNode = TextNode() self.titleNode.displaysAsynchronously = false self.titleNode.isUserInteractionEnabled = false self.textNode = TextNodeWithEntities() self.textNode.textNode.displaysAsynchronously = false self.textNode.textNode.isUserInteractionEnabled = false self.imageNode = TransformImageNode() self.imageNode.contentAnimations = [.subsequentUpdates] self.imageNodeContainer = ASDisplayNode() self.closeButton = HighlightableButtonNode() self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.closeButton.displaysAsynchronously = false super.init() self.addSubnode(self.contextContainer) self.contextContainer.addSubnode(self.clippingContainer) self.clippingContainer.addSubnode(self.contentContainer) self.contentTextContainer.addSubnode(self.titleNode) self.contentTextContainer.addSubnode(self.adNode) self.contentTextContainer.addSubnode(self.textNode.textNode) self.contentContainer.addSubnode(self.contentTextContainer) self.imageNodeContainer.addSubnode(self.imageNode) self.contentContainer.addSubnode(self.imageNodeContainer) self.tapButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside]) self.tapButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.adNode.layer.removeAnimation(forKey: "opacity") strongSelf.adNode.alpha = 0.4 strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") strongSelf.titleNode.alpha = 0.4 strongSelf.textNode.textNode.layer.removeAnimation(forKey: "opacity") strongSelf.textNode.textNode.alpha = 0.4 strongSelf.imageNode.layer.removeAnimation(forKey: "opacity") strongSelf.imageNode.alpha = 0.4 strongSelf.removeTextNode.layer.removeAnimation(forKey: "opacity") strongSelf.removeTextNode.alpha = 0.4 strongSelf.removeBackgroundNode.layer.removeAnimation(forKey: "opacity") strongSelf.removeBackgroundNode.alpha = 0.4 } else { strongSelf.adNode.alpha = 1.0 strongSelf.adNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.titleNode.alpha = 1.0 strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.textNode.textNode.alpha = 1.0 strongSelf.textNode.textNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.imageNode.alpha = 1.0 strongSelf.imageNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.removeTextNode.alpha = 1.0 strongSelf.removeTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.removeBackgroundNode.alpha = 1.0 strongSelf.removeBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.contextContainer.addSubnode(self.tapButton) self.contextContainer.addSubnode(self.removeBackgroundNode) self.contextContainer.addSubnode(self.removeTextNode) self.contextContainer.addSubnode(self.removeButtonNode) self.addSubnode(self.separatorNode) self.removeButtonNode.addTarget(self, action: #selector(self.removePressed), forControlEvents: [.touchUpInside]) self.removeButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.removeTextNode.layer.removeAnimation(forKey: "opacity") strongSelf.removeTextNode.alpha = 0.4 strongSelf.removeBackgroundNode.layer.removeAnimation(forKey: "opacity") strongSelf.removeBackgroundNode.alpha = 0.4 } else { strongSelf.removeTextNode.alpha = 1.0 strongSelf.removeTextNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.removeBackgroundNode.alpha = 1.0 strongSelf.removeBackgroundNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.contextContainer.activated = { [weak self] gesture, _ in guard let self, let message = self.message else { return } self.controllerInteraction?.adContextAction(message, self.contextContainer, gesture) } self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside]) self.addSubnode(self.closeButton) } deinit { self.fetchDisposable.dispose() } private var theme: PresentationTheme? @objc private func closePressed() { if self.context.isPremium, let adAttribute = self.message?.adAttribute { self.controllerInteraction?.removeAd(adAttribute.opaqueId) } else { self.controllerInteraction?.openNoAdsDemo() } } func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { self.message = interfaceState.adMessage if self.theme !== interfaceState.theme { self.theme = interfaceState.theme self.separatorNode.backgroundColor = interfaceState.theme.rootController.navigationBar.separatorColor self.removeBackgroundNode.image = generateStretchableFilledCircleImage(diameter: 15.0, color: interfaceState.theme.chat.inputPanel.panelControlAccentColor.withMultipliedAlpha(0.1)) self.removeTextNode.attributedText = NSAttributedString(string: interfaceState.strings.Chat_BotAd_WhatIsThis, font: Font.regular(11.0), textColor: interfaceState.theme.chat.inputPanel.panelControlAccentColor) self.closeButton.setImage(PresentationResourcesChat.chatInputPanelCloseIconImage(interfaceState.theme), for: []) } self.contextContainer.isGestureEnabled = false let panelHeight: CGFloat var hasCloseButton = true if let message = interfaceState.adMessage { panelHeight = self.enqueueTransition(width: width, leftInset: leftInset, rightInset: rightInset, transition: .immediate, animation: nil, message: message, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, dateTimeFormat: interfaceState.dateTimeFormat, accountPeerId: self.context.account.peerId, firstTime: false, isReplyThread: false, translateToLanguage: nil) hasCloseButton = message.media.isEmpty } else { panelHeight = 50.0 } self.contextContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: width, height: UIScreenPixel))) self.tapButton.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) self.clippingContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) self.contentContainer.frame = CGRect(origin: CGPoint(), size: CGSize(width: width, height: panelHeight)) let contentRightInset: CGFloat = 14.0 + rightInset let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0)) self.closeButton.frame = CGRect(origin: CGPoint(x: width - contentRightInset - closeButtonSize.width, y: floorToScreenPixels((panelHeight - closeButtonSize.height) / 2.0)), size: closeButtonSize) self.closeButton.isHidden = !hasCloseButton self.currentLayout = (width, leftInset, rightInset) return panelHeight } private func enqueueTransition(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, animation: PinnedMessageAnimation?, message: Message, theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, dateTimeFormat: PresentationDateTimeFormat, accountPeerId: PeerId, firstTime: Bool, isReplyThread: Bool, translateToLanguage: String?) -> CGFloat { var animationTransition: ContainedViewLayoutTransition = .immediate if let animation = animation { animationTransition = .animated(duration: 0.2, curve: .easeInOut) if let copyView = self.textNode.textNode.view.snapshotView(afterScreenUpdates: false) { let offset: CGFloat switch animation { case .slideToTop: offset = -10.0 case .slideToBottom: offset = 10.0 } copyView.frame = self.textNode.textNode.frame self.textNode.textNode.view.superview?.addSubview(copyView) copyView.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: offset), duration: 0.2, removeOnCompletion: false, additive: true) copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak copyView] _ in copyView?.removeFromSuperview() }) self.textNode.textNode.layer.animatePosition(from: CGPoint(x: 0.0, y: -offset), to: CGPoint(), duration: 0.2, additive: true) self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } let makeAdLayout = TextNode.asyncLayout(self.adNode) let makeTitleLayout = TextNode.asyncLayout(self.titleNode) let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) let imageNodeLayout = self.imageNode.asyncLayout() let previousMediaReference = self.previousMediaReference let context = self.context let contentLeftInset: CGFloat = leftInset + 18.0 let contentRightInset: CGFloat = rightInset + 9.0 var textRightInset: CGFloat = 0.0 var updatedMediaReference: AnyMediaReference? var imageDimensions: CGSize? if !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 { updatedMediaReference = .message(message: MessageReference(message), media: file) if !file.isInstantVideo && !file.isSticker, let representation = largestImageRepresentation(file.previewRepresentations) { imageDimensions = representation.dimensions.cgSize } else if file.isAnimated, let dimensions = file.dimensions { imageDimensions = dimensions.cgSize } break } else if let paidContent = media as? TelegramMediaPaidContent, let firstMedia = paidContent.extendedMedia.first { switch firstMedia { case let .preview(dimensions, immediateThumbnailData, _): let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) if let dimensions { imageDimensions = dimensions.cgSize } updatedMediaReference = .standalone(media: thumbnailMedia) case let .full(fullMedia): updatedMediaReference = .message(message: MessageReference(message), media: fullMedia) if let image = fullMedia as? TelegramMediaImage { if let representation = largestRepresentationForPhoto(image) { imageDimensions = representation.dimensions.cgSize } break } else if let file = fullMedia as? TelegramMediaFile { if let dimensions = file.dimensions { imageDimensions = dimensions.cgSize } break } } } } } let imageBoundingSize = CGSize(width: 48.0, height: 48.0) var applyImage: (() -> Void)? if let imageDimensions { applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: 3.0), imageSize: imageDimensions.aspectFilled(imageBoundingSize), boundingSize: imageBoundingSize, intrinsicInsets: UIEdgeInsets())) textRightInset += imageBoundingSize.width + 18.0 } else { textRightInset = 27.0 } 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 } var updateImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedFetchMediaSignal: Signal? if mediaUpdated { if let updatedMediaReference = updatedMediaReference, imageDimensions != nil { if let imageReference = updatedMediaReference.concrete(TelegramMediaImage.self) { if imageReference.media.representations.isEmpty { updateImageSignal = chatSecretPhoto(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, ignoreFullSize: true, synchronousLoad: true) } else { updateImageSignal = chatMessagePhotoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), photoReference: imageReference, blurred: false) } } else if let fileReference = updatedMediaReference.concrete(TelegramMediaFile.self) { if fileReference.media.isAnimatedSticker { let dimensions = fileReference.media.dimensions ?? PixelDimensions(width: 512, height: 512) updateImageSignal = chatMessageAnimatedSticker(postbox: context.account.postbox, userLocation: .peer(message.id.peerId), file: fileReference.media, small: false, size: dimensions.cgSize.aspectFitted(CGSize(width: 160.0, height: 160.0))) updatedFetchMediaSignal = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, userLocation: .peer(message.id.peerId), userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(fileReference.media.resource)) } else if fileReference.media.isVideo || fileReference.media.isAnimated { updateImageSignal = chatMessageVideoThumbnail(account: context.account, userLocation: .peer(message.id.peerId), fileReference: fileReference, blurred: false) } else if let iconImageRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) { updateImageSignal = chatWebpageSnippetFile(account: context.account, userLocation: .peer(message.id.peerId), mediaReference: fileReference.abstract, representation: iconImageRepresentation) } } } else { updateImageSignal = .single({ _ in return nil }) } } let textConstrainedSize = CGSize(width: width - contentLeftInset - contentRightInset - textRightInset, height: CGFloat.greatestFiniteMagnitude) let (adLayout, adApply) = makeAdLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: strings.Chat_BotAd_Title, font: Font.semibold(14.0), textColor: theme.chat.inputPanel.panelControlAccentColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: .zero)) var titleText: String = "" if let author = message.author { titleText = EnginePeer(author).compactDisplayTitle } let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: titleText, font: Font.semibold(14.0), textColor: theme.chat.inputPanel.primaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: .zero)) let (textString, _, isText) = descriptionStringForMessage(contentSettings: context.currentContentSettings.with { $0 }, message: EngineMessage(message), strings: strings, nameDisplayOrder: nameDisplayOrder, dateTimeFormat: dateTimeFormat, accountPeerId: accountPeerId) let messageText: NSAttributedString let textFont = Font.regular(14.0) if isText { var text = message.text var messageEntities = message.textEntitiesAttribute?.entities ?? [] if let translateToLanguage = 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 switch entity.type { case .CustomEmoji: return true default: return false } } let textColor = theme.chat.inputPanel.primaryTextColor if entities.count > 0 { messageText = stringWithAppliedEntities(trimToLineCount(text, lineCount: 1), 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: foldLineBreaks(text), font: textFont, textColor: textColor) } } else { messageText = NSAttributedString(string: foldLineBreaks(textString.string), font: textFont, textColor: message.media.isEmpty || message.media.first is TelegramMediaWebpage ? theme.chat.inputPanel.primaryTextColor : theme.chat.inputPanel.secondaryTextColor) } let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: messageText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: .zero)) var panelHeight: CGFloat = 0.0 if let _ = imageDimensions { panelHeight = 9.0 + imageBoundingSize.height + 9.0 } var textHeight: CGFloat var titleOnSeparateLine = false if textLayout.numberOfLines == 1 || contentLeftInset + adLayout.size.width + 2.0 + titleLayout.size.width > width - contentRightInset - textRightInset { textHeight = adLayout.size.height + titleLayout.size.height + textLayout.size.height + 15.0 titleOnSeparateLine = true } else { textHeight = titleLayout.size.height + textLayout.size.height + 15.0 } panelHeight = max(panelHeight, textHeight) Queue.mainQueue().async { let _ = adApply() let _ = titleApply() var textArguments: TextNodeWithEntities.Arguments? if let cache = self.animationCache, let renderer = self.animationRenderer { textArguments = TextNodeWithEntities.Arguments( context: self.context, cache: cache, renderer: renderer, placeholderColor: theme.list.mediaPlaceholderColor, attemptSynchronous: false ) } let _ = textApply(textArguments) self.previousMediaReference = updatedMediaReference let textContainerFrame = CGRect(origin: CGPoint(x: contentLeftInset, y: 0.0), size: CGSize(width: width, height: panelHeight)) animationTransition.updateFrameAdditive(node: self.contentTextContainer, frame: textContainerFrame) let removeTextSize = self.removeTextNode.updateLayout(CGSize(width: width, height: .greatestFiniteMagnitude)) if titleOnSeparateLine { self.adNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 9.0), size: adLayout.size) self.titleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 26.0), size: titleLayout.size) self.textNode.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 43.0), size: textLayout.size) self.removeTextNode.frame = CGRect(origin: CGPoint(x: contentLeftInset + adLayout.size.width + 8.0, y: 11.0 - UIScreenPixel), size: removeTextSize) self.removeBackgroundNode.frame = self.removeTextNode.frame.insetBy(dx: -5.0, dy: -1.0) self.removeButtonNode.frame = self.removeBackgroundNode.frame } else { self.adNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 9.0), size: adLayout.size) self.titleNode.frame = CGRect(origin: CGPoint(x: adLayout.size.width + 2.0, y: 9.0), size: titleLayout.size) self.textNode.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 26.0), size: textLayout.size) self.removeTextNode.frame = CGRect(origin: CGPoint(x: contentLeftInset + adLayout.size.width + 2.0 + titleLayout.size.width + 8.0, y: 11.0 - UIScreenPixel), size: removeTextSize) self.removeBackgroundNode.frame = self.removeTextNode.frame.insetBy(dx: -5.0, dy: -1.0) self.removeButtonNode.frame = self.removeBackgroundNode.frame } self.textNode.visibilityRect = CGRect.infinite self.imageNodeContainer.frame = CGRect(origin: CGPoint(x: width - contentRightInset - imageBoundingSize.width, y: 9.0), size: imageBoundingSize) self.imageNode.frame = CGRect(origin: CGPoint(), size: imageBoundingSize) if let applyImage = applyImage { applyImage() animationTransition.updateSublayerTransformScale(node: self.imageNodeContainer, scale: 1.0) animationTransition.updateAlpha(node: self.imageNodeContainer, alpha: 1.0, beginWithCurrentState: true) } else { animationTransition.updateSublayerTransformScale(node: self.imageNodeContainer, scale: 0.1) animationTransition.updateAlpha(node: self.imageNodeContainer, alpha: 0.0, beginWithCurrentState: true) } if let updateImageSignal = updateImageSignal { self.imageNode.setSignal(updateImageSignal) } if let updatedFetchMediaSignal = updatedFetchMediaSignal { self.fetchDisposable.set(updatedFetchMediaSignal.startStrict()) } } return panelHeight } @objc func tapped() { guard let message = self.message else { return } self.controllerInteraction?.activateAdAction(message.id, nil, false, false) } @objc func removePressed() { guard let message = self.message else { return } self.controllerInteraction?.adContextAction(message, self.contextContainer, nil) } }