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 final class MessageReactionButtonsNode: ASDisplayNode { enum DisplayType { case incoming case outgoing case freeform } enum DisplayAlignment { case left case right } private var bubbleBackgroundNode: WallpaperBubbleBackgroundNode? private let container: ReactionButtonsAsyncLayoutContainer private let backgroundMaskView: UIView private var backgroundMaskButtons: [String: UIView] = [:] var reactionSelected: ((String) -> Void)? override init() { self.container = ReactionButtonsAsyncLayoutContainer() self.backgroundMaskView = UIView() super.init() } func update() { } func prepareUpdate( context: AccountContext, presentationData: ChatPresentationData, presentationContext: ChatPresentationContext, availableReactions: AvailableReactions?, reactions: ReactionsMessageAttribute, message: Message, alignment: DisplayAlignment, constrainedWidth: CGFloat, type: DisplayType ) -> (proposedWidth: CGFloat, continueLayout: (CGFloat) -> (size: CGSize, apply: (ListViewItemUpdateAnimation) -> Void)) { let reactionColors: ReactionButtonComponent.Colors switch type { case .incoming: reactionColors = ReactionButtonComponent.Colors( deselectedBackground: presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(0.1).argb, selectedBackground: presentationData.theme.theme.chat.message.incoming.accentControlColor.withMultipliedAlpha(1.0).argb, deselectedForeground: presentationData.theme.theme.chat.message.incoming.accentTextColor.argb, selectedForeground: presentationData.theme.theme.chat.message.incoming.bubble.withWallpaper.fill.last!.argb ) case .outgoing: reactionColors = ReactionButtonComponent.Colors( deselectedBackground: presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(0.1).argb, selectedBackground: presentationData.theme.theme.chat.message.outgoing.accentControlColor.withMultipliedAlpha(1.0).argb, deselectedForeground: presentationData.theme.theme.chat.message.outgoing.accentTextColor.argb, selectedForeground: presentationData.theme.theme.chat.message.outgoing.bubble.withWallpaper.fill.last!.argb ) case .freeform: reactionColors = ReactionButtonComponent.Colors( deselectedBackground: selectDateFillStaticColor(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper).argb, selectedBackground: UIColor(white: 1.0, alpha: 0.8).argb, deselectedForeground: UIColor(white: 1.0, alpha: 1.0).argb, selectedForeground: UIColor(white: 0.0, alpha: 0.1).argb ) } let reactionButtonsResult = self.container.update( context: context, action: { [weak self] value in guard let strongSelf = self else { return } strongSelf.reactionSelected?(value) }, reactions: reactions.reactions.map { reaction in var iconFile: TelegramMediaFile? if let availableReactions = availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == reaction.value { iconFile = availableReaction.staticIcon break } } } var peers: [EnginePeer] = [] 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) { peers.removeAll() } return ReactionButtonsLayoutContainer.Reaction( reaction: ReactionButtonComponent.Reaction( value: reaction.value, iconFile: iconFile ), count: Int(reaction.count), peers: peers, isSelected: reaction.isSelected ) }, colors: reactionColors, constrainedWidth: constrainedWidth ) 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 += 6.0 } reactionButtonsSize.height += item.size.height currentRowWidth = 0.0 } if !currentRowWidth.isZero { currentRowWidth += 6.0 } currentRowWidth += item.size.width } if !currentRowWidth.isZero && !reactionButtonsResult.items.isEmpty { reactionButtonsSize.width = max(reactionButtonsSize.width, currentRowWidth) if !reactionButtonsSize.height.isZero { reactionButtonsSize.height += 6.0 } 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 } 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) } let reactionButtons = reactionButtonsResult.apply(animation) var validIds = Set() for item in reactionButtons.items { validIds.insert(item.value) switch alignment { case .left: if reactionButtonPosition.x + item.size.width > boundingWidth { reactionButtonPosition.x = 0.0 reactionButtonPosition.y += item.size.height + 6.0 } case .right: if reactionButtonPosition.x - item.size.width < -1.0 { reactionButtonPosition.x = size.width + 1.0 reactionButtonPosition.y += item.size.height + 6.0 } } let itemFrame: CGRect switch alignment { case .left: itemFrame = CGRect(origin: reactionButtonPosition, size: item.size) reactionButtonPosition.x += item.size.width + 6.0 case .right: itemFrame = CGRect(origin: CGPoint(x: reactionButtonPosition.x - item.size.width, y: reactionButtonPosition.y), size: item.size) reactionButtonPosition.x -= item.size.width + 6.0 } let itemMaskFrame = itemFrame.offsetBy(dx: backgroundInsets, dy: backgroundInsets) let itemMaskView: UIView if let current = strongSelf.backgroundMaskButtons[item.value] { itemMaskView = current } else { itemMaskView = UIView() itemMaskView.backgroundColor = .black itemMaskView.clipsToBounds = true itemMaskView.layer.cornerRadius = 15.0 strongSelf.backgroundMaskButtons[item.value] = itemMaskView } if item.view.superview == nil { strongSelf.view.addSubview(item.view) if animation.isAnimated { item.view.layer.animateScale(from: 0.01, to: 1.0, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) item.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } item.view.frame = itemFrame } else { animation.animator.updateFrame(layer: item.view.layer, frame: itemFrame, completion: nil) } if itemMaskView.superview == nil { strongSelf.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) } } var removeMaskIds: [String] = [] 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?.removeFromSuperview() }) } else { view.removeFromSuperview() } } } for id in removeMaskIds { strongSelf.backgroundMaskButtons.removeValue(forKey: id) } for view in reactionButtons.removedViews { 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?.removeFromSuperview() }) } else { view.removeFromSuperview() } } }) }) } private var absoluteRect: (CGRect, CGSize)? 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) } } 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) } } func offset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.offset(value: value, animationCurve: animationCurve, duration: duration) } } func offsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { if let bubbleBackgroundNode = self.bubbleBackgroundNode { bubbleBackgroundNode.offsetSpring(value: value, duration: duration, damping: damping) } } func reactionTargetView(value: String) -> UIView? { for (key, button) in self.container.buttons { if key == value { return button.iconView } } return nil } func animateIn(animation: ListViewItemUpdateAnimation) { for (_, button) in self.container.buttons { animation.animator.animateScale(layer: button.layer, from: 0.01, to: 1.0, completion: nil) } } func animateOut(animation: ListViewItemUpdateAnimation) { for (_, button) in self.container.buttons { animation.animator.updateScale(layer: button.layer, scale: 0.01, completion: nil) } } } final class ChatMessageReactionsFooterContentNode: ChatMessageBubbleContentNode { private let buttonsNode: MessageReactionButtonsNode required init() { self.buttonsNode = MessageReactionButtonsNode() super.init() self.addSubnode(self.buttonsNode) self.buttonsNode.reactionSelected = { [weak self] value in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> 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 displaySeparator: Bool let topOffset: CGFloat if case let .linear(top, _) = preparePosition, case .Neighbour(_, .media, _) = top { //displaySeparator = false topOffset = 4.0 } else { //displaySeparator = true topOffset = 0.0 } return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in let reactionsAttribute = mergedMessageReactions(attributes: item.message.attributes) ?? ReactionsMessageAttribute(reactions: [], recentPeers: []) let buttonsUpdate = buttonsNode.prepareUpdate( context: item.context, presentationData: item.presentationData, presentationContext: item.controllerInteraction.presentationContext, availableReactions: item.associatedData.availableReactions, reactions: reactionsAttribute, message: item.message, alignment: .left, constrainedWidth: constrainedSize.width, 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 func animateInsertion(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override func animateAdded(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override 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 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 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 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 .ignore } return .none } override 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 func reactionTargetView(value: String) -> UIView? { return self.buttonsNode.reactionTargetView(value: value) } } final class ChatMessageReactionButtonsNode: ASDisplayNode { final class Arguments { let context: AccountContext let presentationData: ChatPresentationData let presentationContext: ChatPresentationContext let availableReactions: AvailableReactions? let reactions: ReactionsMessageAttribute let message: Message let isIncoming: Bool let constrainedWidth: CGFloat init( context: AccountContext, presentationData: ChatPresentationData, presentationContext: ChatPresentationContext, availableReactions: AvailableReactions?, reactions: ReactionsMessageAttribute, message: Message, isIncoming: Bool, constrainedWidth: CGFloat ) { self.context = context self.presentationData = presentationData self.presentationContext = presentationContext self.availableReactions = availableReactions self.reactions = reactions self.message = message self.isIncoming = isIncoming self.constrainedWidth = constrainedWidth } } private let buttonsNode: MessageReactionButtonsNode var reactionSelected: ((String) -> Void)? override init() { self.buttonsNode = MessageReactionButtonsNode() super.init() self.addSubnode(self.buttonsNode) self.buttonsNode.reactionSelected = { [weak self] value in self?.reactionSelected?(value) } } 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 buttonsUpdate = node.buttonsNode.prepareUpdate( context: arguments.context, presentationData: arguments.presentationData, presentationContext: arguments.presentationContext, availableReactions: arguments.availableReactions, reactions: arguments.reactions, message: arguments.message, alignment: arguments.isIncoming ? .left : .right, 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 }) }) } } func animateIn(animation: ListViewItemUpdateAnimation) { self.buttonsNode.animateIn(animation: animation) self.buttonsNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } 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) } func reactionTargetView(value: String) -> UIView? { return self.buttonsNode.reactionTargetView(value: value) } override 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 } func update(rect: CGRect, within containerSize: CGSize, transition: ContainedViewLayoutTransition) { self.buttonsNode.update(rect: rect, within: containerSize, transition: transition) } func update(rect: CGRect, within containerSize: CGSize, transition: CombinedTransition) { self.buttonsNode.update(rect: rect, within: containerSize, transition: transition) } func offset(value: CGPoint, animationCurve: ContainedViewLayoutTransitionCurve, duration: Double) { self.buttonsNode.offset(value: value, animationCurve: animationCurve, duration: duration) } func offsetSpring(value: CGFloat, duration: Double, damping: CGFloat) { self.buttonsNode.offsetSpring(value: value, duration: duration, damping: damping) } }