import Foundation import UIKit import Display import AsyncDisplayKit import Postbox import TelegramCore import SyncCore import SwiftSignalKit import TelegramPresentationData import TelegramUIPreferences import AccountContext import StickerResources import PhotoResources import TelegramStringFormatting import AnimatedCountLabelNode import AnimatedNavigationStripeNode private enum PinnedMessageAnimation { case slideToTop case slideToBottom } final class ChatPinnedMessageTitlePanelNode: ChatTitleAccessoryPanelNode { private let context: AccountContext private let tapButton: HighlightTrackingButtonNode private let closeButton: HighlightableButtonNode private let listButton: HighlightableButtonNode private let clippingContainer: ASDisplayNode private let contentContainer: ASDisplayNode private let contentTextContainer: ASDisplayNode private let lineNode: AnimatedNavigationStripeNode private let titleNode: AnimatedCountLabelNode private let textNode: TextNode private let imageNode: TransformImageNode private let imageNodeContainer: ASDisplayNode private let separatorNode: ASDisplayNode private var currentLayout: (CGFloat, CGFloat, CGFloat)? private var currentMessage: ChatPinnedMessage? private var previousMediaReference: AnyMediaReference? private var isReplyThread: Bool = false private let fetchDisposable = MetaDisposable() private let queue = Queue() init(context: AccountContext) { self.context = context self.tapButton = HighlightTrackingButtonNode() self.closeButton = HighlightableButtonNode() self.closeButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.closeButton.displaysAsynchronously = false self.listButton = HighlightableButtonNode() self.listButton.hitTestSlop = UIEdgeInsets(top: -8.0, left: -8.0, bottom: -8.0, right: -8.0) self.listButton.displaysAsynchronously = false self.separatorNode = ASDisplayNode() self.separatorNode.isLayerBacked = true self.clippingContainer = ASDisplayNode() self.clippingContainer.clipsToBounds = true self.contentContainer = ASDisplayNode() self.contentTextContainer = ASDisplayNode() self.lineNode = AnimatedNavigationStripeNode() self.titleNode = AnimatedCountLabelNode() self.titleNode.isUserInteractionEnabled = false self.titleNode.reverseAnimationDirection = true self.textNode = TextNode() self.textNode.displaysAsynchronously = false self.textNode.isUserInteractionEnabled = false self.imageNode = TransformImageNode() self.imageNode.contentAnimations = [.subsequentUpdates] self.imageNodeContainer = ASDisplayNode() super.init() self.tapButton.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.titleNode.layer.removeAnimation(forKey: "opacity") strongSelf.titleNode.alpha = 0.4 strongSelf.textNode.layer.removeAnimation(forKey: "opacity") strongSelf.textNode.alpha = 0.4 strongSelf.lineNode.layer.removeAnimation(forKey: "opacity") strongSelf.lineNode.alpha = 0.4 } else { strongSelf.titleNode.alpha = 1.0 strongSelf.titleNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.textNode.alpha = 1.0 strongSelf.textNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) strongSelf.lineNode.alpha = 1.0 strongSelf.lineNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: [.touchUpInside]) self.listButton.addTarget(self, action: #selector(self.listPressed), forControlEvents: [.touchUpInside]) self.addSubnode(self.clippingContainer) self.clippingContainer.addSubnode(self.contentContainer) self.addSubnode(self.lineNode) self.contentTextContainer.addSubnode(self.titleNode) self.contentTextContainer.addSubnode(self.textNode) self.contentContainer.addSubnode(self.contentTextContainer) self.imageNodeContainer.addSubnode(self.imageNode) self.contentContainer.addSubnode(self.imageNodeContainer) self.addSubnode(self.closeButton) self.addSubnode(self.listButton) self.tapButton.addTarget(self, action: #selector(self.tapped), forControlEvents: [.touchUpInside]) self.addSubnode(self.tapButton) self.addSubnode(self.separatorNode) } deinit { self.fetchDisposable.dispose() } private var theme: PresentationTheme? override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState) -> CGFloat { let panelHeight: CGFloat = 50.0 var themeUpdated = false if self.theme !== interfaceState.theme { themeUpdated = true self.theme = interfaceState.theme self.closeButton.setImage(PresentationResourcesChat.chatInputPanelCloseIconImage(interfaceState.theme), for: []) self.listButton.setImage(PresentationResourcesChat.chatInputPanelPinnedListIconImage(interfaceState.theme), for: []) self.backgroundColor = interfaceState.theme.chat.historyNavigation.fillColor self.separatorNode.backgroundColor = interfaceState.theme.chat.historyNavigation.strokeColor } let isReplyThread: Bool if case .replyThread = interfaceState.chatLocation { isReplyThread = true } else { isReplyThread = false } self.isReplyThread = isReplyThread var messageUpdated = false var messageUpdatedAnimation: PinnedMessageAnimation? if let currentMessage = self.currentMessage, let pinnedMessage = interfaceState.pinnedMessage { if currentMessage != pinnedMessage { messageUpdated = true } if currentMessage.message.id != pinnedMessage.message.id { if currentMessage.message.id < pinnedMessage.message.id { messageUpdatedAnimation = .slideToTop } else { messageUpdatedAnimation = .slideToBottom } } } else if (self.currentMessage != nil) != (interfaceState.pinnedMessage != nil) { messageUpdated = true } if messageUpdated || themeUpdated { let previousMessageWasNil = self.currentMessage == nil self.currentMessage = interfaceState.pinnedMessage if let currentMessage = self.currentMessage, let currentLayout = self.currentLayout { self.enqueueTransition(width: currentLayout.0, panelHeight: panelHeight, leftInset: currentLayout.1, rightInset: currentLayout.2, transition: .immediate, animation: messageUpdatedAnimation, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, accountPeerId: self.context.account.peerId, firstTime: previousMessageWasNil, isReplyThread: isReplyThread) } } if isReplyThread { self.closeButton.isHidden = true self.listButton.isHidden = true } else if let currentMessage = self.currentMessage { if currentMessage.totalCount > 1 { self.listButton.isHidden = false self.closeButton.isHidden = true } else { self.listButton.isHidden = true self.closeButton.isHidden = false } } else { self.listButton.isHidden = false self.closeButton.isHidden = true } let rightInset: CGFloat = 18.0 + rightInset let closeButtonSize = self.closeButton.measure(CGSize(width: 100.0, height: 100.0)) transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: width - rightInset - closeButtonSize.width, y: 19.0), size: closeButtonSize)) let listButtonSize = self.listButton.measure(CGSize(width: 100.0, height: 100.0)) transition.updateFrame(node: self.listButton, frame: CGRect(origin: CGPoint(x: width - rightInset - listButtonSize.width + 4.0, y: 13.0), size: listButtonSize)) transition.updateFrame(node: self.separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight - UIScreenPixel), size: CGSize(width: width, height: UIScreenPixel))) self.tapButton.frame = CGRect(origin: CGPoint(), size: CGSize(width: width - rightInset - closeButtonSize.width - 4.0, 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)) if self.currentLayout?.0 != width || self.currentLayout?.1 != leftInset || self.currentLayout?.2 != rightInset { self.currentLayout = (width, leftInset, rightInset) if let currentMessage = self.currentMessage { self.enqueueTransition(width: width, panelHeight: panelHeight, leftInset: leftInset, rightInset: rightInset, transition: .immediate, animation: .none, pinnedMessage: currentMessage, theme: interfaceState.theme, strings: interfaceState.strings, nameDisplayOrder: interfaceState.nameDisplayOrder, accountPeerId: interfaceState.accountPeerId, firstTime: true, isReplyThread: isReplyThread) } } return panelHeight } private func enqueueTransition(width: CGFloat, panelHeight: CGFloat, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition, animation: PinnedMessageAnimation?, pinnedMessage: ChatPinnedMessage, theme: PresentationTheme, strings: PresentationStrings, nameDisplayOrder: PresentationPersonNameOrder, accountPeerId: PeerId, firstTime: Bool, isReplyThread: Bool) { let message = pinnedMessage.message var animationTransition: ContainedViewLayoutTransition = .immediate if let animation = animation { animationTransition = .animated(duration: 0.2, curve: .easeInOut) if false, let copyView = self.contentContainer.view.snapshotView(afterScreenUpdates: false) { let offset: CGFloat switch animation { case .slideToTop: offset = -40.0 case .slideToBottom: offset = 40.0 } copyView.frame = self.contentContainer.frame self.clippingContainer.view.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.contentContainer.layer.animatePosition(from: CGPoint(x: 0.0, y: -offset), to: CGPoint(), duration: 0.2, additive: true) self.contentContainer.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } else if let copyView = self.textNode.view.snapshotView(afterScreenUpdates: false) { let offset: CGFloat switch animation { case .slideToTop: offset = -10.0 case .slideToBottom: offset = 10.0 } copyView.frame = self.textNode.frame self.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.layer.animatePosition(from: CGPoint(x: 0.0, y: -offset), to: CGPoint(), duration: 0.2, additive: true) self.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } let makeTitleLayout = self.titleNode.asyncLayout() let makeTextLayout = TextNode.asyncLayout(self.textNode) let imageNodeLayout = self.imageNode.asyncLayout() let previousMediaReference = self.previousMediaReference let context = self.context let targetQueue: Queue if firstTime { targetQueue = Queue.mainQueue() } else { targetQueue = self.queue } targetQueue.async { [weak self] in let contentLeftInset: CGFloat = leftInset + 10.0 var textLineInset: CGFloat = 10.0 let rightInset: CGFloat = 18.0 + rightInset let textRightInset: CGFloat = 0.0 var updatedMediaReference: AnyMediaReference? var imageDimensions: CGSize? var titleStrings: [AnimatedCountLabelNode.Segment] = [] if pinnedMessage.totalCount > 1 { titleStrings.append(.text(0, NSAttributedString(string: "\(strings.Conversation_PinnedMessage)", font: Font.medium(15.0), textColor: theme.chat.inputPanel.panelControlAccentColor))) titleStrings.append(.text(1, NSAttributedString(string: " #", font: Font.medium(15.0), textColor: theme.chat.inputPanel.panelControlAccentColor))) titleStrings.append(.number(pinnedMessage.index + 1, NSAttributedString(string: "\(pinnedMessage.index + 1)", font: Font.medium(15.0), textColor: theme.chat.inputPanel.panelControlAccentColor))) } else { titleStrings.append(.text(0, NSAttributedString(string: "\(strings.Conversation_PinnedMessage) ", font: Font.medium(15.0), textColor: theme.chat.inputPanel.panelControlAccentColor))) } 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, let representation = largestImageRepresentation(file.previewRepresentations), !file.isSticker { imageDimensions = representation.dimensions.cgSize } break }/* else if let poll = media as? TelegramMediaPoll { switch poll.kind { case .poll: titleString = strings.Conversation_PinnedPoll case .quiz: titleString = strings.Conversation_PinnedQuiz } }*/ } if isReplyThread { let titleString: String if let author = message.effectiveAuthor { titleString = author.displayTitle(strings: strings, displayOrder: nameDisplayOrder) } else { titleString = "" } titleStrings = [.text(0, NSAttributedString(string: titleString, font: Font.medium(15.0), textColor: theme.chat.inputPanel.panelControlAccentColor))] } var applyImage: (() -> Void)? if let imageDimensions = imageDimensions { let boundingSize = CGSize(width: 35.0, height: 35.0) applyImage = imageNodeLayout(TransformImageArguments(corners: ImageCorners(radius: 2.0), imageSize: imageDimensions.aspectFilled(boundingSize), boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())) textLineInset += 9.0 + 35.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) { updateImageSignal = chatMessagePhotoThumbnail(account: context.account, photoReference: imageReference) } 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, file: fileReference.media, small: false, size: dimensions.cgSize.aspectFitted(CGSize(width: 160.0, height: 160.0))) updatedFetchMediaSignal = fetchedMediaResource(mediaBox: context.account.postbox.mediaBox, reference: fileReference.resourceReference(fileReference.media.resource)) } else if fileReference.media.isVideo { updateImageSignal = chatMessageVideoThumbnail(account: context.account, fileReference: fileReference) } else if let iconImageRepresentation = smallestImageRepresentation(fileReference.media.previewRepresentations) { updateImageSignal = chatWebpageSnippetFile(account: context.account, fileReference: fileReference, representation: iconImageRepresentation) } } } else { updateImageSignal = .single({ _ in return nil }) } } let (titleLayout, titleApply) = makeTitleLayout(CGSize(width: width - textLineInset - contentLeftInset - rightInset - textRightInset, height: CGFloat.greatestFiniteMagnitude), titleStrings) let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: foldLineBreaks(descriptionStringForMessage(contentSettings: context.currentContentSettings.with { $0 }, message: message, strings: strings, nameDisplayOrder: nameDisplayOrder, accountPeerId: accountPeerId).0), font: Font.regular(15.0), textColor: message.media.isEmpty || message.media.first is TelegramMediaWebpage ? theme.chat.inputPanel.primaryTextColor : theme.chat.inputPanel.secondaryTextColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: width - textLineInset - contentLeftInset - rightInset - textRightInset, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 0.0, bottom: 2.0, right: 0.0))) Queue.mainQueue().async { if let strongSelf = self { let _ = titleApply(animation != nil) let _ = textApply() strongSelf.previousMediaReference = updatedMediaReference animationTransition.updateFrameAdditive(node: strongSelf.contentTextContainer, frame: CGRect(origin: CGPoint(x: contentLeftInset + textLineInset, y: 0.0), size: CGSize())) strongSelf.titleNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 5.0), size: titleLayout.size) strongSelf.textNode.frame = CGRect(origin: CGPoint(x: 0.0, y: 23.0), size: textLayout.size) let lineFrame = CGRect(origin: CGPoint(x: contentLeftInset, y: 0.0), size: CGSize(width: 2.0, height: panelHeight)) animationTransition.updateFrame(node: strongSelf.lineNode, frame: lineFrame) strongSelf.lineNode.update( colors: AnimatedNavigationStripeNode.Colors( foreground: theme.chat.inputPanel.panelControlAccentColor, background: theme.chat.inputPanel.panelControlAccentColor.withAlphaComponent(0.5), clearBackground: theme.chat.inputPanel.panelBackgroundColor ), configuration: AnimatedNavigationStripeNode.Configuration( height: panelHeight, index: pinnedMessage.index, count: pinnedMessage.totalCount ), transition: animationTransition ) strongSelf.imageNodeContainer.frame = CGRect(origin: CGPoint(x: contentLeftInset + 9.0, y: 7.0), size: CGSize(width: 35.0, height: 35.0)) strongSelf.imageNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 35.0, height: 35.0)) if let applyImage = applyImage { applyImage() animationTransition.updateSublayerTransformScale(node: strongSelf.imageNodeContainer, scale: 1.0) animationTransition.updateAlpha(node: strongSelf.imageNodeContainer, alpha: 1.0, beginWithCurrentState: true) } else { animationTransition.updateSublayerTransformScale(node: strongSelf.imageNodeContainer, scale: 0.1) animationTransition.updateAlpha(node: strongSelf.imageNodeContainer, alpha: 0.0, beginWithCurrentState: true) } if let updateImageSignal = updateImageSignal { strongSelf.imageNode.setSignal(updateImageSignal) } if let updatedFetchMediaSignal = updatedFetchMediaSignal { strongSelf.fetchDisposable.set(updatedFetchMediaSignal.start()) } } } } } @objc func tapped() { if let interfaceInteraction = self.interfaceInteraction, let message = self.currentMessage { if self.isReplyThread { interfaceInteraction.scrollToTop() } else { interfaceInteraction.navigateToMessage(message.message.id, false, true) } } } @objc func closePressed() { if let interfaceInteraction = self.interfaceInteraction, let message = self.currentMessage { interfaceInteraction.unpinMessage(message.message.id, true) } } @objc func listPressed() { if let interfaceInteraction = self.interfaceInteraction, let message = self.currentMessage { interfaceInteraction.openPinnedList(message.message.id) } } }