import Foundation import UIKit import Postbox import Display import AsyncDisplayKit import SwiftSignalKit import TelegramCore import TelegramPresentationData import RadialStatusNode import AnimatedCountLabelNode import AnimatedAvatarSetNode import ReactionButtonListComponent import AccountContext import WallpaperBackgroundNode import ChatControllerInteraction import ChatMessageBubbleContentNode import ChatMessageItemCommon private let tagImage: UIImage? = { return generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/ReactionTagBackground"), color: .white)?.stretchableImage(withLeftCapWidth: 8, topCapHeight: 15) }() public final class MessageReactionButtonsNode: ASDisplayNode { public enum DisplayType { case incoming case outgoing case freeform } public enum DisplayAlignment { case left case right case center } private var bubbleBackgroundNode: WallpaperBubbleBackgroundNode? private let container: ReactionButtonsAsyncLayoutContainer private var backgroundMaskView: UIView? private var backgroundMaskButtons: [MessageReaction.Reaction: UIView] = [:] public var reactionSelected: ((MessageReaction.Reaction, ContextExtractedContentContainingView?) -> Void)? public var openReactionPreview: ((ContextGesture?, ContextExtractedContentContainingView, MessageReaction.Reaction) -> Void)? override public init() { self.container = ReactionButtonsAsyncLayoutContainer() super.init() } deinit { } public func update() { } public func prepareUpdate( context: AccountContext, presentationData: ChatPresentationData, presentationContext: ChatPresentationContext, availableReactions: AvailableReactions?, savedMessageTags: SavedMessageTags?, reactions: ReactionsMessageAttribute, accountPeer: EnginePeer?, message: Message, associatedData: ChatMessageItemAssociatedData, alignment: DisplayAlignment, constrainedWidth: CGFloat, type: DisplayType ) -> (proposedWidth: CGFloat, continueLayout: (CGFloat) -> (size: CGSize, apply: (ListViewItemUpdateAnimation) -> Void)) { let reactionColors: ReactionButtonComponent.Colors let themeColors: PresentationThemeBubbleColorComponents switch type { case .incoming: themeColors = bubbleColorComponents(theme: presentationData.theme.theme, incoming: true, wallpaper: !presentationData.theme.wallpaper.isEmpty) reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, deselectedForeground: themeColors.reactionInactiveForeground.argb, selectedForeground: themeColors.reactionActiveForeground.argb, deselectedStarsBackground: themeColors.reactionStarsInactiveBackground.argb, selectedStarsBackground: themeColors.reactionStarsActiveBackground.argb, deselectedStarsForeground: themeColors.reactionStarsInactiveForeground.argb, selectedStarsForeground: themeColors.reactionStarsActiveForeground.argb, extractedBackground: presentationData.theme.theme.contextMenu.backgroundColor.argb, extractedForeground: presentationData.theme.theme.contextMenu.primaryColor.argb, extractedSelectedForeground: presentationData.theme.theme.overallDarkAppearance ? themeColors.reactionActiveForeground.argb : presentationData.theme.theme.list.itemCheckColors.foregroundColor.argb, deselectedMediaPlaceholder: themeColors.reactionInactiveMediaPlaceholder.argb, selectedMediaPlaceholder: themeColors.reactionActiveMediaPlaceholder.argb ) case .outgoing: themeColors = bubbleColorComponents(theme: presentationData.theme.theme, incoming: false, wallpaper: !presentationData.theme.wallpaper.isEmpty) reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, deselectedForeground: themeColors.reactionInactiveForeground.argb, selectedForeground: themeColors.reactionActiveForeground.argb, deselectedStarsBackground: themeColors.reactionStarsInactiveBackground.argb, selectedStarsBackground: themeColors.reactionStarsActiveBackground.argb, deselectedStarsForeground: themeColors.reactionStarsInactiveForeground.argb, selectedStarsForeground: themeColors.reactionStarsActiveForeground.argb, extractedBackground: presentationData.theme.theme.contextMenu.backgroundColor.argb, extractedForeground: presentationData.theme.theme.contextMenu.primaryColor.argb, extractedSelectedForeground: presentationData.theme.theme.overallDarkAppearance ? themeColors.reactionActiveForeground.argb : presentationData.theme.theme.list.itemCheckColors.foregroundColor.argb, deselectedMediaPlaceholder: themeColors.reactionInactiveMediaPlaceholder.argb, selectedMediaPlaceholder: themeColors.reactionActiveMediaPlaceholder.argb ) case .freeform: if presentationData.theme.wallpaper.isEmpty { themeColors = presentationData.theme.theme.chat.message.freeform.withoutWallpaper } else { themeColors = presentationData.theme.theme.chat.message.freeform.withWallpaper } reactionColors = ReactionButtonComponent.Colors( deselectedBackground: selectReactionFillStaticColor(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper).argb, selectedBackground: themeColors.reactionActiveBackground.argb, deselectedForeground: themeColors.reactionInactiveForeground.argb, selectedForeground: themeColors.reactionActiveForeground.argb, deselectedStarsBackground: selectReactionFillStaticColor(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper, isStars: true).argb, selectedStarsBackground: themeColors.reactionStarsActiveBackground.argb, deselectedStarsForeground: themeColors.reactionStarsInactiveForeground.argb, selectedStarsForeground: themeColors.reactionStarsActiveForeground.argb, extractedBackground: presentationData.theme.theme.contextMenu.backgroundColor.argb, extractedForeground: presentationData.theme.theme.contextMenu.primaryColor.argb, extractedSelectedForeground: presentationData.theme.theme.contextMenu.primaryColor.argb, deselectedMediaPlaceholder: themeColors.reactionInactiveMediaPlaceholder.argb, selectedMediaPlaceholder: themeColors.reactionActiveMediaPlaceholder.argb ) } var totalReactionCount: Int = 0 for reaction in reactions.reactions { totalReactionCount += Int(reaction.count) } let isTag = message.areReactionsTags(accountPeerId: context.account.peerId) let reactionButtonsResult = self.container.update( context: context, action: { [weak self] _, value, sourceView in guard let self else { return } self.reactionSelected?(value, sourceView) }, reactions: reactions.reactions.map { reaction in var centerAnimation: TelegramMediaFile? var animationFileId: Int64? switch reaction.value { case .builtin: if let availableReactions = availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == reaction.value { centerAnimation = availableReaction.centerAnimation break } } } case let .custom(fileId): animationFileId = fileId case .stars: if let availableReactions = availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == reaction.value { centerAnimation = availableReaction.centerAnimation break } } } } var peers: [EnginePeer] = [] if message.id.peerId.namespace == Namespaces.Peer.CloudUser { if reaction.isSelected, let accountPeer = accountPeer { peers.append(accountPeer) } if !reaction.isSelected || reaction.count >= 2 { if let peer = message.peers[message.id.peerId] { peers.append(EnginePeer(peer)) } } } else { if let channel = message.peers[message.id.peerId] as? TelegramChannel, case .broadcast = channel.info { } else { for recentPeer in reactions.recentPeers { if recentPeer.value == reaction.value { if let peer = message.peers[recentPeer.peerId] { peers.append(EnginePeer(peer)) } } } } if peers.count != Int(reaction.count) || totalReactionCount != reactions.recentPeers.count { peers.removeAll() } } var title: String? if isTag, let savedMessageTags { for tag in savedMessageTags.tags { if tag.reaction == reaction.value { title = tag.title } } } return ReactionButtonsAsyncLayoutContainer.Reaction( reaction: ReactionButtonComponent.Reaction( value: reaction.value, centerAnimation: centerAnimation, animationFileId: animationFileId, title: title ), count: Int(reaction.count), peers: peers, chosenOrder: reaction.chosenOrder ) }, colors: reactionColors, isTag: isTag, constrainedWidth: constrainedWidth ) let itemSpacing: CGFloat = 6.0 var reactionButtonsSize = CGSize() var currentRowWidth: CGFloat = 0.0 for item in reactionButtonsResult.items { if currentRowWidth + item.size.width > constrainedWidth { reactionButtonsSize.width = max(reactionButtonsSize.width, currentRowWidth) if !reactionButtonsSize.height.isZero { reactionButtonsSize.height += itemSpacing } reactionButtonsSize.height += item.size.height currentRowWidth = 0.0 } if !currentRowWidth.isZero { currentRowWidth += itemSpacing } currentRowWidth += item.size.width } if !currentRowWidth.isZero && !reactionButtonsResult.items.isEmpty { reactionButtonsSize.width = max(reactionButtonsSize.width, currentRowWidth) if !reactionButtonsSize.height.isZero { reactionButtonsSize.height += itemSpacing } reactionButtonsSize.height += reactionButtonsResult.items[0].size.height } let topInset: CGFloat = 0.0 let bottomInset: CGFloat = 2.0 return (proposedWidth: reactionButtonsSize.width, continueLayout: { [weak self] boundingWidth in let size = CGSize(width: boundingWidth, height: topInset + reactionButtonsSize.height + bottomInset) return (size: size, apply: { animation in guard let strongSelf = self else { return } if strongSelf.backgroundMaskView == nil { strongSelf.backgroundMaskView = UIView() } let backgroundInsets: CGFloat = 10.0 switch type { case .freeform: if let backgroundNode = presentationContext.backgroundNode, backgroundNode.hasBubbleBackground(for: .free) { let bubbleBackgroundFrame = CGRect(origin: CGPoint(), size: size).insetBy(dx: -backgroundInsets, dy: -backgroundInsets) if let bubbleBackgroundNode = strongSelf.bubbleBackgroundNode { animation.animator.updateFrame(layer: bubbleBackgroundNode.layer, frame: bubbleBackgroundFrame, completion: nil) if let (rect, containerSize) = strongSelf.absoluteRect { bubbleBackgroundNode.update(rect: rect, within: containerSize, transition: animation.transition) } } else if strongSelf.bubbleBackgroundNode == nil { if let bubbleBackgroundNode = backgroundNode.makeBubbleBackground(for: .free) { strongSelf.bubbleBackgroundNode = bubbleBackgroundNode bubbleBackgroundNode.view.mask = strongSelf.backgroundMaskView strongSelf.insertSubnode(bubbleBackgroundNode, at: 0) bubbleBackgroundNode.frame = bubbleBackgroundFrame } } } else { if let bubbleBackgroundNode = strongSelf.bubbleBackgroundNode { strongSelf.bubbleBackgroundNode = nil bubbleBackgroundNode.removeFromSupernode() } } case .incoming, .outgoing: if let bubbleBackgroundNode = strongSelf.bubbleBackgroundNode { strongSelf.bubbleBackgroundNode = nil bubbleBackgroundNode.removeFromSupernode() } } var reactionButtonPosition: CGPoint switch alignment { case .left: reactionButtonPosition = CGPoint(x: -1.0, y: topInset) case .right: reactionButtonPosition = CGPoint(x: size.width + 1.0, y: topInset) case .center: reactionButtonPosition = CGPoint(x: 0.0, y: topInset) } let reactionButtons = reactionButtonsResult.apply( animation, ReactionButtonsAsyncLayoutContainer.Arguments( animationCache: presentationContext.animationCache, animationRenderer: presentationContext.animationRenderer ) ) var validIds = Set() let layoutItem: (ReactionButtonsAsyncLayoutContainer.ApplyResult.Item, CGRect) -> Void = { item, itemFrame in let itemMaskFrame = itemFrame.offsetBy(dx: backgroundInsets, dy: backgroundInsets) let itemMaskView: UIView if let current = strongSelf.backgroundMaskButtons[item.value] { itemMaskView = current } else { if isTag { itemMaskView = UIImageView(image: tagImage) } else { itemMaskView = UIView() itemMaskView.backgroundColor = .black itemMaskView.clipsToBounds = true itemMaskView.layer.cornerRadius = 15.0 } strongSelf.backgroundMaskButtons[item.value] = itemMaskView } if item.node.view.superview != strongSelf.view { assert(item.node.view.superview == nil) strongSelf.view.addSubview(item.node.view) if animation.isAnimated { item.node.view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) item.node.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } item.node.view.frame = itemFrame } else { animation.animator.updateFrame(layer: item.node.view.layer, frame: itemFrame, completion: nil) } let itemValue = item.value let itemNode = item.node item.node.view.isGestureEnabled = true let canViewReactionList = canViewMessageReactionList(message: message) item.node.view.activateAfterCompletion = !canViewReactionList item.node.view.activated = { [weak itemNode] gesture, _ in guard let strongSelf = self, let itemNode = itemNode else { gesture.cancel() return } strongSelf.openReactionPreview?(gesture, itemNode.view.containerView, itemValue) } item.node.view.additionalActivationProgressLayer = itemMaskView.layer if let backgroundMaskView = strongSelf.backgroundMaskView { if itemMaskView.superview != backgroundMaskView { assert(itemMaskView.superview == nil) backgroundMaskView.addSubview(itemMaskView) if animation.isAnimated { itemMaskView.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) itemMaskView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } itemMaskView.frame = itemMaskFrame } else { animation.animator.updateFrame(layer: itemMaskView.layer, frame: itemMaskFrame, completion: nil) } } } if alignment == .center { var lines: [[ReactionButtonsAsyncLayoutContainer.ApplyResult.Item]] = [] var currentLine: [ReactionButtonsAsyncLayoutContainer.ApplyResult.Item] = [] var currentLineWidth: CGFloat = 0.0 for item in reactionButtons.items { validIds.insert(item.value) let itemWidth = item.size.width if currentLineWidth + itemWidth + (currentLine.isEmpty ? 0 : itemSpacing) > boundingWidth + itemSpacing { lines.append(currentLine) currentLine = [item] currentLineWidth = itemWidth } else { currentLine.append(item) currentLineWidth += (currentLine.isEmpty ? 0 : itemSpacing) + itemWidth } } if !currentLine.isEmpty { lines.append(currentLine) } var yPosition = topInset for line in lines { let totalItemWidth = line.reduce(0.0) { $0 + $1.size.width } + CGFloat(line.count - 1) * itemSpacing let startX = (boundingWidth - totalItemWidth) / 2.0 var xPosition = startX for item in line { let itemFrame = CGRect(origin: CGPoint(x: xPosition, y: yPosition), size: item.size) xPosition += item.size.width + itemSpacing layoutItem(item, itemFrame) } yPosition += line.first!.size.height + itemSpacing } } else { for item in reactionButtons.items { validIds.insert(item.value) switch alignment { case .left: if reactionButtonPosition.x + item.size.width > boundingWidth { reactionButtonPosition.x = -1.0 reactionButtonPosition.y += item.size.height + itemSpacing } case .right: if reactionButtonPosition.x - item.size.width < -1.0 { reactionButtonPosition.x = size.width + 1.0 reactionButtonPosition.y += item.size.height + itemSpacing } default: break } let itemFrame: CGRect switch alignment { case .left, .center: itemFrame = CGRect(origin: reactionButtonPosition, size: item.size) reactionButtonPosition.x += item.size.width + itemSpacing case .right: itemFrame = CGRect(origin: CGPoint(x: reactionButtonPosition.x - item.size.width, y: reactionButtonPosition.y), size: item.size) reactionButtonPosition.x -= item.size.width + itemSpacing } layoutItem(item, itemFrame) } } var removeMaskIds: [MessageReaction.Reaction] = [] for (id, view) in strongSelf.backgroundMaskButtons { if !validIds.contains(id) { removeMaskIds.append(id) if animation.isAnimated { view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak view] _ in view?.layer.removeAllAnimations() view?.removeFromSuperview() }) } else { view.removeFromSuperview() } } } for id in removeMaskIds { strongSelf.backgroundMaskButtons.removeValue(forKey: id) } for node in reactionButtons.removedNodes { if animation.isAnimated { node.view.layer.animateScale(from: 1.0, to: 0.01, duration: 0.2, removeOnCompletion: false) node.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in node.view.removeFromSuperview() }) } else { node.view.removeFromSuperview() } } }) }) } private var absoluteRect: (CGRect, CGSize)? public func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition) { self.absoluteRect = (rect, containerSize) if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.update(rect: rect, within: containerSize, transition: transition) } } public func update(rect: CGRect, within containerSize: CGSize, transition: CombinedTransition) { self.absoluteRect = (rect, containerSize) if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.update(rect: rect, within: containerSize, transition: transition) } } public func offset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.offset(value: value, animationCurve: animationCurve, duration: duration) } } public func offsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.offsetSpring(value: value, duration: duration, damping: damping) } } public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { for (key, button) in self.container.buttons { if key == value { return button.view.iconView } } return nil } public func animateIn(animation: ListViewItemUpdateAnimation) { for (_, button) in self.container.buttons { animation.animator.animateScale(layer: button.view.layer, from: 0.01, to: 1.0, completion: nil) } } public func animateOut(animation: ListViewItemUpdateAnimation) { for (_, button) in self.container.buttons { animation.animator.updateScale(layer: button.view.layer, scale: 0.01, completion: nil) } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { for (_, button) in self.container.buttons { if button.view.frame.contains(point) { if let result = button.view.hitTest(self.view.convert(point, to: button.view), with: event) { return result } } } return nil } } public final class ChatMessageReactionsFooterContentNode: ChatMessageBubbleContentNode { private let buttonsNode: MessageReactionButtonsNode required public init() { self.buttonsNode = MessageReactionButtonsNode() super.init() self.addSubnode(self.buttonsNode) self.buttonsNode.reactionSelected = { [weak self] value, sourceView in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.updateMessageReaction(item.topMessage, .reaction(value), false, sourceView) } self.buttonsNode.openReactionPreview = { [weak self] gesture, sourceNode, value in guard let strongSelf = self, let item = strongSelf.item else { gesture?.cancel() return } item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value) } } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let buttonsNode = self.buttonsNode return { item, layoutConstants, preparePosition, _, constrainedSize, _ in let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) let topOffset: CGFloat if case let .linear(top, _) = preparePosition, case .Neighbour(_, .media, _) = top { topOffset = 4.0 } else { topOffset = 0.0 } return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in let reactionsAttribute = mergedMessageReactions(attributes: item.message.attributes, isTags: item.message.areReactionsTags(accountPeerId: item.context.account.peerId)) ?? ReactionsMessageAttribute(canViewList: false, isTags: false, reactions: [], recentPeers: [], topPeers: []) let buttonsUpdate = buttonsNode.prepareUpdate( context: item.context, presentationData: item.presentationData, presentationContext: item.controllerInteraction.presentationContext, availableReactions: item.associatedData.availableReactions, savedMessageTags: item.associatedData.savedMessageTags, reactions: reactionsAttribute, accountPeer: item.associatedData.accountPeer, message: item.message, associatedData: item.associatedData, alignment: .left, constrainedWidth: constrainedSize.width - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, type: item.message.effectivelyIncoming(item.context.account.peerId) ? .incoming : .outgoing) return (layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right + buttonsUpdate.proposedWidth, { boundingWidth in var boundingSize = CGSize() let buttonsSizeAndApply = buttonsUpdate.continueLayout(boundingWidth - (layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right)) boundingSize = buttonsSizeAndApply.size boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right boundingSize.height += topOffset + 2.0 return (boundingSize, { [weak self] animation, synchronousLoad, _ in if let strongSelf = self { strongSelf.item = item animation.animator.updateFrame(layer: strongSelf.buttonsNode.layer, frame: CGRect(origin: CGPoint(x: layoutConstants.text.bubbleInsets.left, y: topOffset - 2.0), size: buttonsSizeAndApply.size), completion: nil) buttonsSizeAndApply.apply(animation) let _ = synchronousLoad } }) }) }) } } override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override public func animateAdded(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) self.buttonsNode.animateOut(animation: ListViewItemUpdateAnimation.System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .spring, interactive: false))) } override public func animateInsertionIntoBubble(_ duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) self.layer.animatePosition(from: CGPoint(x: 0.0, y: -self.bounds.height / 2.0), to: CGPoint(), duration: duration, removeOnCompletion: true, additive: true) } override public func animateRemovalFromBubble(_ duration: Double, completion: @escaping () -> Void) { self.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: -self.bounds.height / 2.0), duration: duration, removeOnCompletion: false, additive: true) self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { _ in completion() }) self.buttonsNode.animateOut(animation: ListViewItemUpdateAnimation.System(duration: 0.25, transition: ControlledTransition(duration: 0.25, curve: .spring, interactive: false))) } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: nil), result !== self.buttonsNode.view { return ChatMessageBubbleContentTapAction(content: .ignore) } return ChatMessageBubbleContentTapAction(content: .none) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: event), result !== self.buttonsNode.view { return result } return nil } override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { return self.buttonsNode.reactionTargetView(value: value) } } public final class ChatMessageReactionButtonsNode: ASDisplayNode { public final class Arguments { public let context: AccountContext public let presentationData: ChatPresentationData public let presentationContext: ChatPresentationContext public let availableReactions: AvailableReactions? public let savedMessageTags: SavedMessageTags? public let reactions: ReactionsMessageAttribute public let message: Message public let associatedData: ChatMessageItemAssociatedData public let accountPeer: EnginePeer? public let isIncoming: Bool public let constrainedWidth: CGFloat public let centerAligned: Bool public init( context: AccountContext, presentationData: ChatPresentationData, presentationContext: ChatPresentationContext, availableReactions: AvailableReactions?, savedMessageTags: SavedMessageTags?, reactions: ReactionsMessageAttribute, message: Message, associatedData: ChatMessageItemAssociatedData, accountPeer: EnginePeer?, isIncoming: Bool, constrainedWidth: CGFloat, centerAligned: Bool ) { self.context = context self.presentationData = presentationData self.presentationContext = presentationContext self.availableReactions = availableReactions self.savedMessageTags = savedMessageTags self.reactions = reactions self.message = message self.associatedData = associatedData self.accountPeer = accountPeer self.isIncoming = isIncoming self.constrainedWidth = constrainedWidth self.centerAligned = centerAligned } } private let buttonsNode: MessageReactionButtonsNode public var reactionSelected: ((MessageReaction.Reaction, ContextExtractedContentContainingView?) -> Void)? public var openReactionPreview: ((ContextGesture?, ContextExtractedContentContainingView, MessageReaction.Reaction) -> Void)? override public init() { self.buttonsNode = MessageReactionButtonsNode() super.init() self.addSubnode(self.buttonsNode) self.buttonsNode.reactionSelected = { [weak self] value, sourceView in self?.reactionSelected?(value, sourceView) } self.buttonsNode.openReactionPreview = { [weak self] gesture, sourceNode, value in self?.openReactionPreview?(gesture, sourceNode, value) } } public class func asyncLayout(_ maybeNode: ChatMessageReactionButtonsNode?) -> (_ arguments: ChatMessageReactionButtonsNode.Arguments) -> (minWidth: CGFloat, layout: (CGFloat) -> (size: CGSize, apply: (_ animation: ListViewItemUpdateAnimation) -> ChatMessageReactionButtonsNode)) { return { arguments in let node = maybeNode ?? ChatMessageReactionButtonsNode() let alignment: MessageReactionButtonsNode.DisplayAlignment if arguments.centerAligned { alignment = .center } else { alignment = arguments.isIncoming ? .left : .right } let buttonsUpdate = node.buttonsNode.prepareUpdate( context: arguments.context, presentationData: arguments.presentationData, presentationContext: arguments.presentationContext, availableReactions: arguments.availableReactions, savedMessageTags: arguments.savedMessageTags, reactions: arguments.reactions, accountPeer: arguments.accountPeer, message: arguments.message, associatedData: arguments.associatedData, alignment: alignment, constrainedWidth: arguments.constrainedWidth, type: .freeform ) return (buttonsUpdate.proposedWidth, { constrainedWidth in let buttonsResult = buttonsUpdate.continueLayout(constrainedWidth) return (CGSize(width: constrainedWidth, height: buttonsResult.size.height), { animation in node.buttonsNode.frame = CGRect(origin: CGPoint(), size: buttonsResult.size) buttonsResult.apply(animation) return node }) }) } } public func animateIn(animation: ListViewItemUpdateAnimation) { self.buttonsNode.animateIn(animation: animation) self.buttonsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } public func animateOut(animation: ListViewItemUpdateAnimation, completion: @escaping () -> Void) { self.buttonsNode.animateOut(animation: animation) animation.animator.updateAlpha(layer: self.buttonsNode.layer, alpha: 0.0, completion: { _ in completion() }) animation.animator.updateFrame(layer: self.buttonsNode.layer, frame: self.buttonsNode.layer.frame.offsetBy(dx: 0.0, dy: -self.buttonsNode.layer.bounds.height / 2.0), completion: nil) } public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { return self.buttonsNode.reactionTargetView(value: value) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let result = self.buttonsNode.hitTest(self.view.convert(point, to: self.buttonsNode.view), with: event), result !== self.buttonsNode.view { return result } return nil } public func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition) { self.buttonsNode.update(rect: rect, within: containerSize, transition: transition) } public func update(rect: CGRect, within containerSize: CGSize, transition: CombinedTransition) { self.buttonsNode.update(rect: rect, within: containerSize, transition: transition) } public func offset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { self.buttonsNode.offset(value: value, animationCurve: animationCurve, duration: duration) } public func offsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { self.buttonsNode.offsetSpring(value: value, duration: duration, damping: damping) } }