import Foundation import UIKit import AsyncDisplayKit import Postbox import TelegramCore import Display import SwiftSignalKit import TelegramPresentationData import AccountContext import AppBundle import ReactionButtonListComponent import ReactionImageComponent private func maybeAddRotationAnimation(_ layer: CALayer, duration: Double) { if let _ = layer.animation(forKey: "clockFrameAnimation") { return } let basicAnimation = CABasicAnimation(keyPath: "transform.rotation.z") basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) basicAnimation.duration = duration basicAnimation.fromValue = NSNumber(value: Float(0.0)) basicAnimation.toValue = NSNumber(value: Float(Double.pi * 2.0)) basicAnimation.repeatCount = Float.infinity basicAnimation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear) basicAnimation.beginTime = 1.0 layer.add(basicAnimation, forKey: "clockFrameAnimation") } enum ChatMessageDateAndStatusOutgoingType: Equatable { case Sent(read: Bool) case Sending case Failed } enum ChatMessageDateAndStatusType: Equatable { case BubbleIncoming case BubbleOutgoing(ChatMessageDateAndStatusOutgoingType) case ImageIncoming case ImageOutgoing(ChatMessageDateAndStatusOutgoingType) case FreeIncoming case FreeOutgoing(ChatMessageDateAndStatusOutgoingType) } private let reactionCountFont = Font.semibold(11.0) private let reactionFont = Font.regular(12.0) private final class StatusReactionNode: ASDisplayNode { let iconView: ReactionIconView private let iconImageDisposable = MetaDisposable() private var theme: PresentationTheme? private var value: String? private var isSelected: Bool? override init() { self.iconView = ReactionIconView() super.init() self.view.addSubview(self.iconView) } deinit { self.iconImageDisposable.dispose() } func update(context: AccountContext, type: ChatMessageDateAndStatusType, value: String, file: TelegramMediaFile?, isSelected: Bool, count: Int, theme: PresentationTheme, wallpaper: TelegramWallpaper, animated: Bool) { if self.value != value { self.value = value let boundingImageSize = CGSize(width: 14.0, height: 14.0) let defaultImageSize = CGSize(width: boundingImageSize.width + floor(boundingImageSize.width * 0.5 * 2.0), height: boundingImageSize.height + floor(boundingImageSize.height * 0.5 * 2.0)) let imageSize: CGSize if let file = file { self.iconImageDisposable.set((reactionStaticImage(context: context, animation: file, pixelSize: CGSize(width: 72.0, height: 72.0)) |> deliverOnMainQueue).start(next: { [weak self] data in guard let strongSelf = self else { return } if data.isComplete, let dataValue = try? Data(contentsOf: URL(fileURLWithPath: data.path)) { if let image = UIImage(data: dataValue) { strongSelf.iconView.imageView.image = image } } })) imageSize = file.dimensions?.cgSize.aspectFitted(defaultImageSize) ?? defaultImageSize } else { imageSize = defaultImageSize } let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((boundingImageSize.width - imageSize.width) / 2.0), y: floorToScreenPixels((boundingImageSize.height - imageSize.height) / 2.0)), size: imageSize) self.iconView.frame = iconFrame self.iconView.update(size: iconFrame.size, transition: .immediate) } } } class ChatMessageDateAndStatusNode: ASDisplayNode { struct TrailingReactionSettings { var displayInline: Bool var preferAdditionalInset: Bool init(displayInline: Bool, preferAdditionalInset: Bool) { self.displayInline = displayInline self.preferAdditionalInset = preferAdditionalInset } } struct StandaloneReactionSettings { init() { } } enum LayoutInput { case trailingContent(contentWidth: CGFloat, reactionSettings: TrailingReactionSettings?) case standalone(reactionSettings: StandaloneReactionSettings?) var displayInlineReactions: Bool { switch self { case let .trailingContent(_, reactionSettings): if let reactionSettings = reactionSettings { return reactionSettings.displayInline } else { return false } case let .standalone(reactionSettings): if let _ = reactionSettings { return true } else { return false } } } } struct Arguments { var context: AccountContext var presentationData: ChatPresentationData var edited: Bool var impressionCount: Int? var dateText: String var type: ChatMessageDateAndStatusType var layoutInput: LayoutInput var constrainedSize: CGSize var availableReactions: AvailableReactions? var reactions: [MessageReaction] var reactionPeers: [(String, EnginePeer)] var replyCount: Int var isPinned: Bool var hasAutoremove: Bool var canViewReactionList: Bool init( context: AccountContext, presentationData: ChatPresentationData, edited: Bool, impressionCount: Int?, dateText: String, type: ChatMessageDateAndStatusType, layoutInput: LayoutInput, constrainedSize: CGSize, availableReactions: AvailableReactions?, reactions: [MessageReaction], reactionPeers: [(String, EnginePeer)], replyCount: Int, isPinned: Bool, hasAutoremove: Bool, canViewReactionList: Bool ) { self.context = context self.presentationData = presentationData self.edited = edited self.impressionCount = impressionCount self.dateText = dateText self.type = type self.layoutInput = layoutInput self.availableReactions = availableReactions self.constrainedSize = constrainedSize self.reactions = reactions self.reactionPeers = reactionPeers self.replyCount = replyCount self.isPinned = isPinned self.hasAutoremove = hasAutoremove self.canViewReactionList = canViewReactionList } } private var backgroundNode: ASImageNode? private var blurredBackgroundNode: NavigationBackgroundNode? private var checkSentNode: ASImageNode? private var checkReadNode: ASImageNode? private var clockFrameNode: ASImageNode? private var clockMinNode: ASImageNode? private let dateNode: TextNode private var impressionIcon: ASImageNode? private var reactionNodes: [String: StatusReactionNode] = [:] private let reactionButtonsContainer = ReactionButtonsAsyncLayoutContainer() private var reactionCountNode: TextNode? private var reactionButtonNode: HighlightTrackingButtonNode? private var repliesIcon: ASImageNode? private var selfExpiringIcon: ASImageNode? private var replyCountNode: TextNode? private var type: ChatMessageDateAndStatusType? private var theme: ChatPresentationThemeData? private var layoutSize: CGSize? private var tapGestureRecognizer: UITapGestureRecognizer? var openReplies: (() -> Void)? var pressed: (() -> Void)? { didSet { if self.pressed != nil { if self.tapGestureRecognizer == nil { let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:))) self.tapGestureRecognizer = tapGestureRecognizer self.view.addGestureRecognizer(tapGestureRecognizer) } } else if let tapGestureRecognizer = self.tapGestureRecognizer{ self.tapGestureRecognizer = nil self.view.removeGestureRecognizer(tapGestureRecognizer) } } } var reactionSelected: ((String) -> Void)? var openReactionPreview: ((ContextGesture?, ContextExtractedContentContainingView, String) -> Void)? override init() { self.dateNode = TextNode() self.dateNode.isUserInteractionEnabled = false self.dateNode.displaysAsynchronously = false self.dateNode.contentsScale = UIScreenScale self.dateNode.contentMode = .topLeft super.init() self.addSubnode(self.dateNode) } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.pressed?() } } func asyncLayout() -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)) { let dateLayout = TextNode.asyncLayout(self.dateNode) var checkReadNode = self.checkReadNode var checkSentNode = self.checkSentNode var clockFrameNode = self.clockFrameNode var clockMinNode = self.clockMinNode var currentBackgroundNode = self.backgroundNode var currentImpressionIcon = self.impressionIcon var currentRepliesIcon = self.repliesIcon let currentType = self.type let currentTheme = self.theme let makeReplyCountLayout = TextNode.asyncLayout(self.replyCountNode) let makeReactionCountLayout = TextNode.asyncLayout(self.reactionCountNode) let reactionButtonsContainer = self.reactionButtonsContainer return { [weak self] arguments in let dateColor: UIColor var backgroundImage: UIImage? var blurredBackgroundColor: (UIColor, Bool)? var outgoingStatus: ChatMessageDateAndStatusOutgoingType? var leftInset: CGFloat let loadedCheckFullImage: UIImage? let loadedCheckPartialImage: UIImage? let clockFrameImage: UIImage? let clockMinImage: UIImage? var impressionImage: UIImage? var repliesImage: UIImage? let themeUpdated = arguments.presentationData.theme != currentTheme || arguments.type != currentType let graphics = PresentationResourcesChat.principalGraphics(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper, bubbleCorners: arguments.presentationData.chatBubbleCorners) let isDefaultWallpaper = serviceMessageColorHasDefaultWallpaper(arguments.presentationData.theme.wallpaper) let offset: CGFloat = -UIScreenPixel let checkSize: CGFloat = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 11.0 / 17.0)) let reactionColors: ReactionButtonComponent.Colors switch arguments.type { case .BubbleIncoming, .ImageIncoming, .FreeIncoming: let themeColors = bubbleColorComponents(theme: arguments.presentationData.theme.theme, incoming: true, wallpaper: !arguments.presentationData.theme.wallpaper.isEmpty) reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, deselectedForeground: themeColors.reactionInactiveForeground.argb, selectedForeground: themeColors.reactionActiveForeground.argb, extractedBackground: arguments.presentationData.theme.theme.contextMenu.backgroundColor.argb, extractedForeground: arguments.presentationData.theme.theme.contextMenu.primaryColor.argb ) case .BubbleOutgoing, .ImageOutgoing, .FreeOutgoing: let themeColors = bubbleColorComponents(theme: arguments.presentationData.theme.theme, incoming: false, wallpaper: !arguments.presentationData.theme.wallpaper.isEmpty) reactionColors = ReactionButtonComponent.Colors( deselectedBackground: themeColors.reactionInactiveBackground.argb, selectedBackground: themeColors.reactionActiveBackground.argb, deselectedForeground: themeColors.reactionInactiveForeground.argb, selectedForeground: themeColors.reactionActiveForeground.argb, extractedBackground: arguments.presentationData.theme.theme.contextMenu.backgroundColor.argb, extractedForeground: arguments.presentationData.theme.theme.contextMenu.primaryColor.argb ) } switch arguments.type { case .BubbleIncoming: dateColor = arguments.presentationData.theme.theme.chat.message.incoming.secondaryTextColor leftInset = 5.0 loadedCheckFullImage = PresentationResourcesChat.chatOutgoingFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatOutgoingPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockBubbleIncomingFrameImage clockMinImage = graphics.clockBubbleIncomingMinImage if arguments.impressionCount != nil { impressionImage = graphics.incomingDateAndStatusImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.incomingDateAndStatusRepliesIcon } else if arguments.isPinned { repliesImage = graphics.incomingDateAndStatusPinnedIcon } case let .BubbleOutgoing(status): dateColor = arguments.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor outgoingStatus = status leftInset = 5.0 loadedCheckFullImage = PresentationResourcesChat.chatOutgoingFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatOutgoingPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockBubbleOutgoingFrameImage clockMinImage = graphics.clockBubbleOutgoingMinImage if arguments.impressionCount != nil { impressionImage = graphics.outgoingDateAndStatusImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.outgoingDateAndStatusRepliesIcon } else if arguments.isPinned { repliesImage = graphics.outgoingDateAndStatusPinnedIcon } case .ImageIncoming: dateColor = arguments.presentationData.theme.theme.chat.message.mediaDateAndStatusTextColor backgroundImage = graphics.dateAndStatusMediaBackground leftInset = 0.0 loadedCheckFullImage = PresentationResourcesChat.chatMediaFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatMediaPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockMediaFrameImage clockMinImage = graphics.clockMediaMinImage if arguments.impressionCount != nil { impressionImage = graphics.mediaImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.mediaRepliesIcon } else if arguments.isPinned { repliesImage = graphics.mediaPinnedIcon } case let .ImageOutgoing(status): dateColor = arguments.presentationData.theme.theme.chat.message.mediaDateAndStatusTextColor outgoingStatus = status backgroundImage = graphics.dateAndStatusMediaBackground leftInset = 0.0 loadedCheckFullImage = PresentationResourcesChat.chatMediaFullCheck(arguments.presentationData.theme.theme, size: checkSize) loadedCheckPartialImage = PresentationResourcesChat.chatMediaPartialCheck(arguments.presentationData.theme.theme, size: checkSize) clockFrameImage = graphics.clockMediaFrameImage clockMinImage = graphics.clockMediaMinImage if arguments.impressionCount != nil { impressionImage = graphics.mediaImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.mediaRepliesIcon } else if arguments.isPinned { repliesImage = graphics.mediaPinnedIcon } case .FreeIncoming: let serviceColor = serviceMessageColorComponents(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper) dateColor = serviceColor.primaryText blurredBackgroundColor = (selectDateFillStaticColor(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper), dateFillNeedsBlur(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper)) leftInset = 0.0 loadedCheckFullImage = PresentationResourcesChat.chatFreeFullCheck(arguments.presentationData.theme.theme, size: checkSize, isDefaultWallpaper: isDefaultWallpaper) loadedCheckPartialImage = PresentationResourcesChat.chatFreePartialCheck(arguments.presentationData.theme.theme, size: checkSize, isDefaultWallpaper: isDefaultWallpaper) clockFrameImage = graphics.clockFreeFrameImage clockMinImage = graphics.clockFreeMinImage if arguments.impressionCount != nil { impressionImage = graphics.freeImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.freeRepliesIcon } else if arguments.isPinned { repliesImage = graphics.freePinnedIcon } case let .FreeOutgoing(status): let serviceColor = serviceMessageColorComponents(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper) dateColor = serviceColor.primaryText outgoingStatus = status blurredBackgroundColor = (selectDateFillStaticColor(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper), dateFillNeedsBlur(theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper)) leftInset = 0.0 loadedCheckFullImage = PresentationResourcesChat.chatFreeFullCheck(arguments.presentationData.theme.theme, size: checkSize, isDefaultWallpaper: isDefaultWallpaper) loadedCheckPartialImage = PresentationResourcesChat.chatFreePartialCheck(arguments.presentationData.theme.theme, size: checkSize, isDefaultWallpaper: isDefaultWallpaper) clockFrameImage = graphics.clockFreeFrameImage clockMinImage = graphics.clockFreeMinImage if arguments.impressionCount != nil { impressionImage = graphics.freeImpressionIcon } if arguments.replyCount != 0 { repliesImage = graphics.freeRepliesIcon } else if arguments.isPinned { repliesImage = graphics.freePinnedIcon } } var updatedDateText = arguments.dateText if arguments.edited { updatedDateText = "\(arguments.presentationData.strings.Conversation_MessageEditedLabel) \(updatedDateText)" } if let impressionCount = arguments.impressionCount { updatedDateText = compactNumericCountString(impressionCount, decimalSeparator: arguments.presentationData.dateTimeFormat.decimalSeparator) + " " + updatedDateText } let dateFont = Font.regular(floor(arguments.presentationData.fontSize.baseDisplaySize * 11.0 / 17.0)) let (date, dateApply) = dateLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: updatedDateText, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: arguments.constrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let checkOffset = floor(arguments.presentationData.fontSize.baseDisplaySize * 6.0 / 17.0) let statusWidth: CGFloat var checkSentFrame: CGRect? var checkReadFrame: CGRect? var clockPosition = CGPoint() var impressionSize = CGSize() var impressionWidth: CGFloat = 0.0 if let impressionImage = impressionImage { if currentImpressionIcon == nil { let iconNode = ASImageNode() iconNode.isLayerBacked = true iconNode.displayWithoutProcessing = true iconNode.displaysAsynchronously = false currentImpressionIcon = iconNode } impressionSize = impressionImage.size impressionWidth = impressionSize.width + 3.0 } else { currentImpressionIcon = nil } var repliesIconSize = CGSize() if let repliesImage = repliesImage { if currentRepliesIcon == nil { let iconNode = ASImageNode() iconNode.isLayerBacked = true iconNode.displayWithoutProcessing = true iconNode.displaysAsynchronously = false currentRepliesIcon = iconNode } repliesIconSize = repliesImage.size } else { currentRepliesIcon = nil } if let outgoingStatus = outgoingStatus { switch outgoingStatus { case .Sending: statusWidth = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)) if checkReadNode == nil { checkReadNode = ASImageNode() checkReadNode?.isLayerBacked = true checkReadNode?.displaysAsynchronously = false checkReadNode?.displayWithoutProcessing = true } if checkSentNode == nil { checkSentNode = ASImageNode() checkSentNode?.isLayerBacked = true checkSentNode?.displaysAsynchronously = false checkSentNode?.displayWithoutProcessing = true } if clockFrameNode == nil { clockFrameNode = ASImageNode() clockFrameNode?.isLayerBacked = true clockFrameNode?.displaysAsynchronously = false clockFrameNode?.displayWithoutProcessing = true clockFrameNode?.frame = CGRect(origin: CGPoint(), size: clockFrameImage?.size ?? CGSize()) } if clockMinNode == nil { clockMinNode = ASImageNode() clockMinNode?.isLayerBacked = true clockMinNode?.displaysAsynchronously = false clockMinNode?.displayWithoutProcessing = true clockMinNode?.frame = CGRect(origin: CGPoint(), size: clockMinImage?.size ?? CGSize()) } clockPosition = CGPoint(x: leftInset + date.size.width + 8.5, y: 7.5 + offset) case let .Sent(read): let hideStatus: Bool switch arguments.type { case .BubbleOutgoing, .FreeOutgoing, .ImageOutgoing: hideStatus = false default: hideStatus = arguments.impressionCount != nil } if hideStatus { statusWidth = 0.0 checkReadNode = nil checkSentNode = nil clockFrameNode = nil clockMinNode = nil } else { statusWidth = floor(floor(arguments.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)) if checkReadNode == nil { checkReadNode = ASImageNode() checkReadNode?.isLayerBacked = true checkReadNode?.displaysAsynchronously = false checkReadNode?.displayWithoutProcessing = true } if checkSentNode == nil { checkSentNode = ASImageNode() checkSentNode?.isLayerBacked = true checkSentNode?.displaysAsynchronously = false checkSentNode?.displayWithoutProcessing = true } clockFrameNode = nil clockMinNode = nil let checkSize = loadedCheckFullImage!.size if read { checkReadFrame = CGRect(origin: CGPoint(x: leftInset + impressionWidth + date.size.width + 5.0 + statusWidth - checkSize.width, y: 3.0 + offset), size: checkSize) } checkSentFrame = CGRect(origin: CGPoint(x: leftInset + impressionWidth + date.size.width + 5.0 + statusWidth - checkSize.width - checkOffset, y: 3.0 + offset), size: checkSize) } case .Failed: statusWidth = 0.0 checkReadNode = nil checkSentNode = nil clockFrameNode = nil clockMinNode = nil } } else { statusWidth = 0.0 checkReadNode = nil checkSentNode = nil clockFrameNode = nil clockMinNode = nil } var backgroundInsets = UIEdgeInsets() if let _ = backgroundImage { if currentBackgroundNode == nil { let backgroundNode = ASImageNode() backgroundNode.isLayerBacked = true backgroundNode.displayWithoutProcessing = true backgroundNode.displaysAsynchronously = false currentBackgroundNode = backgroundNode } backgroundInsets = UIEdgeInsets(top: 2.0, left: 7.0, bottom: 2.0, right: 7.0) } else if blurredBackgroundColor != nil { backgroundInsets = UIEdgeInsets(top: 2.0, left: 7.0, bottom: 2.0, right: 7.0) } var replyCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? let reactionSize: CGFloat = 17.0 var reactionCountLayoutAndApply: (TextNodeLayout, () -> TextNode)? let reactionSpacing: CGFloat = 2.0 let reactionTrailingSpacing: CGFloat = 6.0 var reactionInset: CGFloat = 0.0 if arguments.layoutInput.displayInlineReactions, !arguments.reactions.isEmpty { reactionInset = -1.0 + CGFloat(arguments.reactions.count) * reactionSize + CGFloat(arguments.reactions.count - 1) * reactionSpacing + reactionTrailingSpacing var count = 0 for reaction in arguments.reactions { count += Int(reaction.count) } let countString: String if count > 1000000 { countString = "\(count / 1000000)M" } else if count > 1000 { countString = "\(count / 1000)K" } else { countString = "\(count)" } if count > arguments.reactions.count { let layoutAndApply = makeReactionCountLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: countString, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0))) reactionInset += layoutAndApply.0.size.width + 4.0 reactionCountLayoutAndApply = layoutAndApply } } if arguments.replyCount > 0 { let countString: String if arguments.replyCount > 1000000 { countString = "\(arguments.replyCount / 1000000)M" } else if arguments.replyCount > 1000 { countString = "\(arguments.replyCount / 1000)K" } else { countString = "\(arguments.replyCount)" } let layoutAndApply = makeReplyCountLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: countString, font: dateFont, textColor: dateColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 100.0, height: 100.0))) reactionInset += 14.0 + layoutAndApply.0.size.width + 4.0 replyCountLayoutAndApply = layoutAndApply } else if arguments.isPinned { reactionInset += 12.0 } leftInset += reactionInset let layoutSize = CGSize(width: leftInset + impressionWidth + date.size.width + statusWidth + backgroundInsets.left + backgroundInsets.right, height: date.size.height + backgroundInsets.top + backgroundInsets.bottom) let verticalReactionsInset: CGFloat let verticalInset: CGFloat let resultingWidth: CGFloat let resultingHeight: CGFloat let reactionButtonsResult: ReactionButtonsAsyncLayoutContainer.Result switch arguments.layoutInput { case .standalone: verticalReactionsInset = 0.0 verticalInset = 0.0 resultingWidth = layoutSize.width resultingHeight = layoutSize.height reactionButtonsResult = reactionButtonsContainer.update( context: arguments.context, action: { value in guard let strongSelf = self else { return } strongSelf.reactionSelected?(value) }, reactions: [], colors: reactionColors, constrainedWidth: arguments.constrainedSize.width ) case let .trailingContent(contentWidth, reactionSettings): if let reactionSettings = reactionSettings, !reactionSettings.displayInline { var totalReactionCount: Int = 0 for reaction in arguments.reactions { totalReactionCount += Int(reaction.count) } reactionButtonsResult = reactionButtonsContainer.update( context: arguments.context, action: { value in guard let strongSelf = self else { return } strongSelf.reactionSelected?(value) }, reactions: arguments.reactions.map { reaction in var centerAnimation: TelegramMediaFile? var legacyIcon: TelegramMediaFile? if let availableReactions = arguments.availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == reaction.value { centerAnimation = availableReaction.centerAnimation legacyIcon = availableReaction.staticIcon break } } } var peers: [EnginePeer] = [] for (value, peer) in arguments.reactionPeers { if value == reaction.value { peers.append(peer) } } if peers.count != Int(reaction.count) || arguments.reactionPeers.count != totalReactionCount { peers.removeAll() } return ReactionButtonsAsyncLayoutContainer.Reaction( reaction: ReactionButtonComponent.Reaction( value: reaction.value, centerAnimation: centerAnimation, legacyIcon: legacyIcon ), count: Int(reaction.count), peers: peers, isSelected: reaction.isSelected ) }, colors: reactionColors, constrainedWidth: arguments.constrainedSize.width ) } else { reactionButtonsResult = reactionButtonsContainer.update( context: arguments.context, action: { value in guard let strongSelf = self else { return } strongSelf.reactionSelected?(value) }, reactions: [], colors: reactionColors, constrainedWidth: arguments.constrainedSize.width ) } var reactionButtonsSize = CGSize() var currentRowWidth: CGFloat = 0.0 for item in reactionButtonsResult.items { if currentRowWidth + item.size.width > arguments.constrainedSize.width { 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 } if reactionButtonsSize.width.isZero { verticalReactionsInset = 0.0 if contentWidth + layoutSize.width > arguments.constrainedSize.width { resultingWidth = layoutSize.width verticalInset = 0.0 resultingHeight = layoutSize.height + verticalInset } else { resultingWidth = contentWidth + layoutSize.width verticalInset = -layoutSize.height resultingHeight = 0.0 } } else { var additionalVerticalInset: CGFloat = 0.0 if let reactionSettings = reactionSettings { if reactionSettings.preferAdditionalInset { verticalReactionsInset = 8.0 additionalVerticalInset += 1.0 } else { verticalReactionsInset = 3.0 } } else { verticalReactionsInset = 0.0 } if currentRowWidth + layoutSize.width > arguments.constrainedSize.width { resultingWidth = max(layoutSize.width, reactionButtonsSize.width) resultingHeight = verticalReactionsInset + reactionButtonsSize.height + 1.0 + layoutSize.height verticalInset = verticalReactionsInset + reactionButtonsSize.height + 3.0 } else { resultingWidth = max(layoutSize.width + currentRowWidth, reactionButtonsSize.width) verticalInset = verticalReactionsInset + reactionButtonsSize.height - layoutSize.height + additionalVerticalInset resultingHeight = verticalReactionsInset + reactionButtonsSize.height + 1.0 } } } return (resultingWidth, { boundingWidth in return (CGSize(width: boundingWidth, height: resultingHeight), { animation in if let strongSelf = self { let leftOffset = boundingWidth - layoutSize.width strongSelf.theme = arguments.presentationData.theme strongSelf.type = arguments.type strongSelf.layoutSize = layoutSize let reactionButtons = reactionButtonsResult.apply(animation) var reactionButtonPosition = CGPoint(x: -1.0, y: verticalReactionsInset) for item in reactionButtons.items { if reactionButtonPosition.x + item.size.width > boundingWidth { reactionButtonPosition.x = -1.0 reactionButtonPosition.y += item.size.height + 6.0 } if item.node.view.superview != strongSelf.view { assert(item.node.view.superview == nil) strongSelf.view.addSubview(item.node.view) item.node.view.frame = CGRect(origin: reactionButtonPosition, size: item.size) 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) } } else { animation.animator.updateFrame(layer: item.node.view.layer, frame: CGRect(origin: reactionButtonPosition, size: item.size), completion: nil) } let itemValue = item.value let itemNode = item.node item.node.view.isGestureEnabled = true let canViewReactionList = arguments.canViewReactionList item.node.view.activateAfterCompletion = !canViewReactionList item.node.view.activated = { [weak itemNode] gesture, _ in guard let strongSelf = self, canViewReactionList else { return } guard let itemNode = itemNode else { return } if let openReactionPreview = strongSelf.openReactionPreview { openReactionPreview(gesture, itemNode.view.containerView, itemValue) } else { gesture.cancel() } } reactionButtonPosition.x += item.size.width + 6.0 } 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() } } if backgroundImage != nil { if let currentBackgroundNode = currentBackgroundNode { if currentBackgroundNode.supernode == nil { strongSelf.backgroundNode = currentBackgroundNode currentBackgroundNode.image = backgroundImage strongSelf.insertSubnode(currentBackgroundNode, at: 0) } else if themeUpdated { currentBackgroundNode.image = backgroundImage } } if let backgroundNode = strongSelf.backgroundNode { animation.animator.updateFrame(layer: backgroundNode.layer, frame: CGRect(origin: CGPoint(), size: layoutSize), completion: nil) } } else { if let backgroundNode = strongSelf.backgroundNode { backgroundNode.removeFromSupernode() strongSelf.backgroundNode = nil } } if let blurredBackgroundColor = blurredBackgroundColor { if let blurredBackgroundNode = strongSelf.blurredBackgroundNode { blurredBackgroundNode.updateColor(color: blurredBackgroundColor.0, enableBlur: blurredBackgroundColor.1, transition: .immediate) animation.animator.updateFrame(layer: blurredBackgroundNode.layer, frame: CGRect(origin: CGPoint(), size: layoutSize), completion: nil) blurredBackgroundNode.update(size: blurredBackgroundNode.bounds.size, cornerRadius: blurredBackgroundNode.bounds.height / 2.0, animator: animation.animator) } else { let blurredBackgroundNode = NavigationBackgroundNode(color: blurredBackgroundColor.0, enableBlur: blurredBackgroundColor.1) strongSelf.blurredBackgroundNode = blurredBackgroundNode strongSelf.insertSubnode(blurredBackgroundNode, at: 0) blurredBackgroundNode.frame = CGRect(origin: CGPoint(), size: layoutSize) blurredBackgroundNode.update(size: blurredBackgroundNode.bounds.size, cornerRadius: blurredBackgroundNode.bounds.height / 2.0, transition: .immediate) } } else if let blurredBackgroundNode = strongSelf.blurredBackgroundNode { strongSelf.blurredBackgroundNode = nil blurredBackgroundNode.removeFromSupernode() } let _ = dateApply() if let currentImpressionIcon = currentImpressionIcon { let impressionIconFrame = CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left, y: backgroundInsets.top + 1.0 + offset + verticalInset + floor((date.size.height - impressionSize.height) / 2.0)), size: impressionSize) currentImpressionIcon.displaysAsynchronously = false if currentImpressionIcon.image !== impressionImage { currentImpressionIcon.image = impressionImage } if currentImpressionIcon.supernode == nil { strongSelf.impressionIcon = currentImpressionIcon strongSelf.addSubnode(currentImpressionIcon) currentImpressionIcon.frame = impressionIconFrame } else { animation.animator.updateFrame(layer: currentImpressionIcon.layer, frame: impressionIconFrame, completion: nil) } } else if let impressionIcon = strongSelf.impressionIcon { impressionIcon.removeFromSupernode() strongSelf.impressionIcon = nil } animation.animator.updateFrame(layer: strongSelf.dateNode.layer, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + backgroundInsets.left + impressionWidth, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: date.size), completion: nil) if let clockFrameNode = clockFrameNode { let clockPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockFrameNode == nil { strongSelf.clockFrameNode = clockFrameNode clockFrameNode.image = clockFrameImage strongSelf.addSubnode(clockFrameNode) clockFrameNode.position = clockPosition } else { if themeUpdated { clockFrameNode.image = clockFrameImage } animation.animator.updatePosition(layer: clockFrameNode.layer, position: clockPosition, completion: nil) } if let clockFrameNode = strongSelf.clockFrameNode { maybeAddRotationAnimation(clockFrameNode.layer, duration: 6.0) } } else if let clockFrameNode = strongSelf.clockFrameNode { clockFrameNode.removeFromSupernode() strongSelf.clockFrameNode = nil } if let clockMinNode = clockMinNode { let clockMinPosition = CGPoint(x: leftOffset + backgroundInsets.left + clockPosition.x + reactionInset, y: backgroundInsets.top + clockPosition.y + verticalInset) if strongSelf.clockMinNode == nil { strongSelf.clockMinNode = clockMinNode clockMinNode.image = clockMinImage strongSelf.addSubnode(clockMinNode) clockMinNode.position = clockMinPosition } else { if themeUpdated { clockMinNode.image = clockMinImage } animation.animator.updatePosition(layer: clockMinNode.layer, position: clockMinPosition, completion: nil) } if let clockMinNode = strongSelf.clockMinNode { maybeAddRotationAnimation(clockMinNode.layer, duration: 1.0) } } else if let clockMinNode = strongSelf.clockMinNode { clockMinNode.removeFromSupernode() strongSelf.clockMinNode = nil } if let checkSentNode = checkSentNode, let checkReadNode = checkReadNode { var animateSentNode = false if strongSelf.checkSentNode == nil { checkSentNode.image = loadedCheckFullImage strongSelf.checkSentNode = checkSentNode strongSelf.addSubnode(checkSentNode) animateSentNode = animation.isAnimated } else if themeUpdated { checkSentNode.image = loadedCheckFullImage } if let checkSentFrame = checkSentFrame { let actualCheckSentFrame = checkSentFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) if checkSentNode.isHidden { animateSentNode = animation.isAnimated checkSentNode.isHidden = false checkSentNode.frame = actualCheckSentFrame } else { animation.animator.updateFrame(layer: checkSentNode.layer, frame: actualCheckSentFrame, completion: nil) } } else { checkSentNode.isHidden = true } var animateReadNode = false if strongSelf.checkReadNode == nil { animateReadNode = animation.isAnimated checkReadNode.image = loadedCheckPartialImage strongSelf.checkReadNode = checkReadNode strongSelf.addSubnode(checkReadNode) } else if themeUpdated { checkReadNode.image = loadedCheckPartialImage } if let checkReadFrame = checkReadFrame { if checkReadNode.isHidden { animateReadNode = animation.isAnimated checkReadNode.frame = checkReadFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset) } else { animation.animator.updateFrame(layer: checkReadNode.layer, frame: checkReadFrame.offsetBy(dx: leftOffset + backgroundInsets.left + reactionInset, dy: backgroundInsets.top + verticalInset), completion: nil) } checkReadNode.isHidden = false } else { checkReadNode.isHidden = true } if animateSentNode { strongSelf.checkSentNode?.layer.animateScale(from: 1.3, to: 1.0, duration: 0.1) } if animateReadNode { strongSelf.checkReadNode?.layer.animateScale(from: 1.3, to: 1.0, duration: 0.1) } } else if let checkSentNode = strongSelf.checkSentNode, let checkReadNode = strongSelf.checkReadNode { checkSentNode.removeFromSupernode() checkReadNode.removeFromSupernode() strongSelf.checkSentNode = nil strongSelf.checkReadNode = nil } var reactionOffset: CGFloat = leftOffset + leftInset - reactionInset + backgroundInsets.left if arguments.layoutInput.displayInlineReactions { var validReactions = Set() for reaction in arguments.reactions.sorted(by: { lhs, rhs in if lhs.isSelected != rhs.isSelected { if lhs.isSelected { return true } else { return false } } else { return lhs.value < rhs.value } }) { let node: StatusReactionNode var animateNode = true if let current = strongSelf.reactionNodes[reaction.value] { node = current } else { animateNode = false node = StatusReactionNode() strongSelf.reactionNodes[reaction.value] = node } validReactions.insert(reaction.value) var centerAnimation: TelegramMediaFile? if let availableReactions = arguments.availableReactions { for availableReaction in availableReactions.reactions { if availableReaction.value == reaction.value { centerAnimation = availableReaction.centerAnimation break } } } node.update(context: arguments.context, type: arguments.type, value: reaction.value, file: centerAnimation, isSelected: reaction.isSelected, count: Int(reaction.count), theme: arguments.presentationData.theme.theme, wallpaper: arguments.presentationData.theme.wallpaper, animated: false) if node.supernode == nil { strongSelf.addSubnode(node) if animation.isAnimated { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } let nodeFrame = CGRect(origin: CGPoint(x: reactionOffset, y: backgroundInsets.top + offset + verticalInset), size: CGSize(width: reactionSize, height: reactionSize)) if animateNode { animation.animator.updateFrame(layer: node.layer, frame: nodeFrame, completion: nil) } else { node.frame = nodeFrame } reactionOffset += reactionSize + reactionSpacing } if !arguments.reactions.isEmpty { reactionOffset += reactionTrailingSpacing } var removeIds: [String] = [] for (id, node) in strongSelf.reactionNodes { if !validReactions.contains(id) { removeIds.append(id) if animation.isAnimated { node.layer.animateScale(from: 1.0, to: 0.1, duration: 0.2, removeOnCompletion: false) node.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak node] _ in node?.layer.removeAllAnimations() node?.removeFromSupernode() }) } else { node.removeFromSupernode() } } } for id in removeIds { strongSelf.reactionNodes.removeValue(forKey: id) } } if let (layout, apply) = reactionCountLayoutAndApply { let node = apply() if strongSelf.reactionCountNode !== node { strongSelf.reactionCountNode?.removeFromSupernode() strongSelf.addSubnode(node) strongSelf.reactionCountNode = node if animation.isAnimated { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } let nodeFrame = CGRect(origin: CGPoint(x: reactionOffset - 4.0, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: layout.size) animation.animator.updateFrame(layer: node.layer, frame: nodeFrame, completion: nil) reactionOffset += 1.0 + layout.size.width + 4.0 } else if let reactionCountNode = strongSelf.reactionCountNode { strongSelf.reactionCountNode = nil if animation.isAnimated { reactionCountNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak reactionCountNode] _ in reactionCountNode?.removeFromSupernode() }) } else { reactionCountNode.removeFromSupernode() } } if let currentRepliesIcon = currentRepliesIcon { currentRepliesIcon.displaysAsynchronously = false if currentRepliesIcon.image !== repliesImage { currentRepliesIcon.image = repliesImage } if currentRepliesIcon.supernode == nil { strongSelf.repliesIcon = currentRepliesIcon strongSelf.addSubnode(currentRepliesIcon) if animation.isAnimated { currentRepliesIcon.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } let repliesIconFrame = CGRect(origin: CGPoint(x: reactionOffset - 2.0, y: backgroundInsets.top + offset + verticalInset + floor((date.size.height - repliesIconSize.height) / 2.0)), size: repliesIconSize) animation.animator.updateFrame(layer: currentRepliesIcon.layer, frame: repliesIconFrame, completion: nil) reactionOffset += 9.0 } else if let repliesIcon = strongSelf.repliesIcon { strongSelf.repliesIcon = nil if animation.isAnimated { repliesIcon.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak repliesIcon] _ in repliesIcon?.removeFromSupernode() }) } else { repliesIcon.removeFromSupernode() } } if let (layout, apply) = replyCountLayoutAndApply { let node = apply() if strongSelf.replyCountNode !== node { strongSelf.replyCountNode?.removeFromSupernode() strongSelf.addSubnode(node) strongSelf.replyCountNode = node if animation.isAnimated { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } let replyCountFrame = CGRect(origin: CGPoint(x: reactionOffset + 4.0, y: backgroundInsets.top + 1.0 + offset + verticalInset), size: layout.size) animation.animator.updateFrame(layer: node.layer, frame: replyCountFrame, completion: nil) reactionOffset += 4.0 + layout.size.width } else if let replyCountNode = strongSelf.replyCountNode { strongSelf.replyCountNode = nil if animation.isAnimated { replyCountNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak replyCountNode] _ in replyCountNode?.removeFromSupernode() }) } else { replyCountNode.removeFromSupernode() } } } }) }) } } static func asyncLayout(_ node: ChatMessageDateAndStatusNode?) -> (_ arguments: Arguments) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> ChatMessageDateAndStatusNode)) { let currentLayout = node?.asyncLayout() return { arguments in let resultNode: ChatMessageDateAndStatusNode let resultSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void)) if let node = node, let currentLayout = currentLayout { resultNode = node resultSuggestedWidthAndContinue = currentLayout(arguments) } else { resultNode = ChatMessageDateAndStatusNode() resultSuggestedWidthAndContinue = resultNode.asyncLayout()(arguments) } return (resultSuggestedWidthAndContinue.0, { boundingWidth in let (size, apply) = resultSuggestedWidthAndContinue.1(boundingWidth) return (size, { animation in apply(animation) return resultNode }) }) } } func reactionView(value: String) -> UIView? { for (id, node) in self.reactionNodes { if id == value { return node.iconView } } for (key, button) in self.reactionButtonsContainer.buttons { if key == value { return button.view.iconView } } return nil } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { for (_, button) in self.reactionButtonsContainer.buttons { if button.view.frame.contains(point) { if let result = button.view.hitTest(self.view.convert(point, to: button.view), with: event) { return result } } } if self.pressed != nil { if self.bounds.contains(point) { return self.view } } return nil } } func shouldDisplayInlineDateReactions(message: Message) -> Bool { if message.id.peerId.namespace == Namespaces.Peer.CloudUser || message.id.peerId.namespace == Namespaces.Peer.SecretChat { return true } return false }