import Foundation import AsyncDisplayKit import Display import Postbox import TelegramCore private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> [(Message, AnyClass)] { var result: [(Message, AnyClass)] = [] var skipText = false var addFinalText = false outer: for message in item.content { inner: for media in message.media { if let _ = media as? TelegramMediaImage { result.append((message, ChatMessageMediaBubbleContentNode.self)) } else if let file = media as? TelegramMediaFile { if file.isVideo || (file.isAnimated && file.dimensions != nil) { result.append((message, ChatMessageMediaBubbleContentNode.self)) } else { result.append((message, ChatMessageFileBubbleContentNode.self)) } } else if let action = media as? TelegramMediaAction { if case .phoneCall = action.action { result.append((message, ChatMessageCallBubbleContentNode.self)) } else { result.append((message, ChatMessageActionBubbleContentNode.self)) } } else if let _ = media as? TelegramMediaMap { result.append((message, ChatMessageMapBubbleContentNode.self)) } else if let _ = media as? TelegramMediaGame { skipText = true result.append((message, ChatMessageGameBubbleContentNode.self)) break inner } else if let _ = media as? TelegramMediaInvoice { skipText = true result.append((message, ChatMessageInvoiceBubbleContentNode.self)) break inner } else if let _ = media as? TelegramMediaContact { result.append((message, ChatMessageContactBubbleContentNode.self)) } else if let _ = media as? TelegramMediaExpiredContent { result.removeAll() result.append((message, ChatMessageActionBubbleContentNode.self)) return result } } if !message.text.isEmpty { if !skipText { if case .group = item.content { addFinalText = true skipText = true } else { result.append((message, ChatMessageTextBubbleContentNode.self)) } } else { if case .group = item.content { addFinalText = false } } } inner: for media in message.media { if let webpage = media as? TelegramMediaWebpage { if case .Loaded = webpage.content { result.append((message, ChatMessageWebpageBubbleContentNode.self)) } break inner } } } if addFinalText && !item.content.firstMessage.text.isEmpty { result.append((item.content.firstMessage, ChatMessageTextBubbleContentNode.self)) } if let additionalContent = item.additionalContent { switch additionalContent { case let .eventLogPreviousMessage(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousMessageContentNode.self)) case let .eventLogPreviousDescription(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousDescriptionContentNode.self)) case let .eventLogPreviousLink(previousMessage): result.append((previousMessage, ChatMessageEventLogPreviousLinkContentNode.self)) } } return result } private let nameFont = Font.medium(14.0) private let inlineBotPrefixFont = Font.regular(14.0) private let inlineBotNameFont = nameFont private let chatMessagePeerIdColors: [UIColor] = [ UIColor(rgb: 0xfc5c51), UIColor(rgb: 0xfa790f), UIColor(rgb: 0x895dd5), UIColor(rgb: 0x0fb297), UIColor(rgb: 0x00c0c2), UIColor(rgb: 0x3ca5ec), UIColor(rgb: 0x3d72ed) ] private enum ContentNodeOperation { case remove(index: Int) case insert(index: Int, node: ChatMessageBubbleContentNode) } class ChatMessageBubbleItemNode: ChatMessageItemView { private let backgroundNode: ChatMessageBackground private var transitionClippingNode: ASDisplayNode? private var selectionNode: ChatMessageSelectionNode? private var deliveryFailedNode: ChatMessageDeliveryFailedNode? private var swipeToReplyNode: ChatMessageSwipeToReplyNode? private var swipeToReplyFeedback: HapticFeedback? private var nameNode: TextNode? private var adminBadgeNode: TextNode? private var forwardInfoNode: ChatMessageForwardInfoNode? private var replyInfoNode: ChatMessageReplyInfoNode? private var contentNodes: [ChatMessageBubbleContentNode] = [] private var mosaicStatusNode: ChatMessageDateAndStatusNode? private var actionButtonsNode: ChatMessageActionButtonsNode? private var shareButtonNode: HighlightableButtonNode? private var backgroundType: ChatMessageBackgroundType? private var highlightedState: Bool = false private var backgroundFrameTransition: (CGRect, CGRect)? private var currentSwipeToReplyTranslation: CGFloat = 0.0 private var appliedItem: ChatMessageItem? override var visibility: ListViewItemNodeVisibility { didSet { if self.visibility != oldValue { for contentNode in self.contentNodes { contentNode.visibility = self.visibility } } } } required init() { self.backgroundNode = ChatMessageBackground() super.init(layerBacked: false) self.addSubnode(self.backgroundNode) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { super.animateInsertion(currentTimestamp, duration: duration, short: short) if let subnodes = self.subnodes { for node in subnodes { if node !== self.accessoryItemNode { node.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } } } override func animateRemoved(_ currentTimestamp: Double, duration: Double) { super.animateRemoved(currentTimestamp, duration: duration) self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } override func animateAdded(_ currentTimestamp: Double, duration: Double) { super.animateAdded(currentTimestamp, duration: duration) self.backgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.nameNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.adminBadgeNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.forwardInfoNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.replyInfoNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) for contentNode in self.contentNodes { contentNode.animateAdded(currentTimestamp, duration: duration) } } override func didLoad() { super.didLoad() let recognizer = TapLongTapOrDoubleTapGestureRecognizer(target: self, action: #selector(self.tapLongTapOrDoubleTapGesture(_:))) recognizer.tapActionAtPoint = { [weak self] point in if let strongSelf = self { if let shareButtonNode = strongSelf.shareButtonNode, shareButtonNode.frame.contains(point) { return .fail } if let avatarNode = strongSelf.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { return .waitForSingleTap } if let nameNode = strongSelf.nameNode, nameNode.frame.contains(point) { if let item = strongSelf.item { for attribute in item.message.attributes { if let _ = attribute as? InlineBotMessageAttribute { return .waitForSingleTap } } } } if let replyInfoNode = strongSelf.replyInfoNode, replyInfoNode.frame.contains(point) { return .waitForSingleTap } if let forwardInfoNode = strongSelf.forwardInfoNode, forwardInfoNode.frame.contains(point) { return .waitForSingleTap } for contentNode in strongSelf.contentNodes { let tapAction = contentNode.tapActionAtPoint(CGPoint(x: point.x - contentNode.frame.minX, y: point.y - contentNode.frame.minY), gesture: .tap) switch tapAction { case .none: break case .ignore: return .fail case .url, .peerMention, .textMention, .botCommand, .hashtag, .instantPage, .call, .openMessage: return .waitForSingleTap } } if !strongSelf.backgroundNode.frame.contains(point) { return .waitForSingleTap } } return .waitForDoubleTap } recognizer.highlight = { [weak self] point in if let strongSelf = self { for contentNode in strongSelf.contentNodes { var translatedPoint: CGPoint? if let point = point, contentNode.frame.insetBy(dx: -4.0, dy: -4.0).contains(point) { translatedPoint = CGPoint(x: point.x - contentNode.frame.minX, y: point.y - contentNode.frame.minY) } contentNode.updateTouchesAtPoint(translatedPoint) } } } self.view.addGestureRecognizer(recognizer) let replyRecognizer = ChatSwipeToReplyRecognizer(target: self, action: #selector(self.swipeToReplyGesture(_:))) replyRecognizer.shouldBegin = { [weak self] in if let strongSelf = self, let item = strongSelf.item { if strongSelf.selectionNode != nil { return false } for media in item.content.firstMessage.media { if let _ = media as? TelegramMediaExpiredContent { return false } else if let media = media as? TelegramMediaAction { if case .phoneCall(_, _, _) = media.action { } else { return false } } } return item.controllerInteraction.canSetupReply(item.message) } return false } self.view.addGestureRecognizer(replyRecognizer) } override func asyncLayout() -> (_ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, Bool) -> Void) { var currentContentClassesPropertiesAndLayouts: [(Message, AnyClass, Bool, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))))] = [] for contentNode in self.contentNodes { if let message = contentNode.item?.message { currentContentClassesPropertiesAndLayouts.append((message, type(of: contentNode) as AnyClass, contentNode.supportsMosaic, contentNode.asyncLayoutContent())) } else { assertionFailure() } } let authorNameLayout = TextNode.asyncLayout(self.nameNode) let adminBadgeLayout = TextNode.asyncLayout(self.adminBadgeNode) let forwardInfoLayout = ChatMessageForwardInfoNode.asyncLayout(self.forwardInfoNode) let replyInfoLayout = ChatMessageReplyInfoNode.asyncLayout(self.replyInfoNode) let actionButtonsLayout = ChatMessageActionButtonsNode.asyncLayout(self.actionButtonsNode) let mosaicStatusLayout = ChatMessageDateAndStatusNode.asyncLayout(self.mosaicStatusNode) let currentShareButtonNode = self.shareButtonNode let layoutConstants = self.layoutConstants let currentItem = self.appliedItem return { item, params, mergedTop, mergedBottom, dateHeaderAtBottom in let baseWidth = params.width - params.leftInset - params.rightInset let content = item.content let firstMessage = content.firstMessage let incoming = item.content.effectivelyIncoming(item.account.peerId) var effectiveAuthor: Peer? var ignoreForward = false let displayAuthorInfo: Bool let avatarInset: CGFloat var hasAvatar = false var allowFullWidth = false switch item.chatLocation { case let .peer(peerId): if item.message.id.peerId == item.account.peerId { if let forwardInfo = item.content.firstMessage.forwardInfo { ignoreForward = true effectiveAuthor = forwardInfo.author } displayAuthorInfo = !mergedTop.merged && incoming && effectiveAuthor != nil } else { effectiveAuthor = firstMessage.author displayAuthorInfo = !mergedTop.merged && incoming && peerId.isGroupOrChannel && effectiveAuthor != nil } if peerId != item.account.peerId { if peerId.isGroupOrChannel && effectiveAuthor != nil { var isBroadcastChannel = false if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case .broadcast = peer.info { isBroadcastChannel = true allowFullWidth = true } if !isBroadcastChannel { hasAvatar = item.content.firstMessage.effectivelyIncoming(item.account.peerId) } } } else if incoming { hasAvatar = true } case .group: allowFullWidth = true hasAvatar = true displayAuthorInfo = true } if let forwardInfo = item.content.firstMessage.forwardInfo, forwardInfo.source == nil, forwardInfo.author.id.namespace == Namespaces.Peer.CloudUser { for media in item.content.firstMessage.media { if let file = media as? TelegramMediaFile, file.isMusic { ignoreForward = true break } } } if hasAvatar { avatarInset = layoutConstants.avatarDiameter } else { avatarInset = 0.0 } var needShareButton = false if item.message.flags.contains(.Failed) { needShareButton = false } else if item.message.id.peerId == item.account.peerId { for attribute in item.content.firstMessage.attributes { if let _ = attribute as? SourceReferenceMessageAttribute { needShareButton = true break } } } else if item.message.effectivelyIncoming(item.account.peerId) { if let peer = item.message.peers[item.message.id.peerId] { if let channel = peer as? TelegramChannel { if case .broadcast = channel.info { needShareButton = true } } } if let info = item.message.forwardInfo { if let author = info.author as? TelegramUser, let _ = author.botInfo, !item.message.media.isEmpty && !(item.message.media.first is TelegramMediaAction) { needShareButton = true } else if let author = info.author as? TelegramChannel, case .broadcast = author.info { needShareButton = true } } if !needShareButton, let author = item.message.author as? TelegramUser, let _ = author.botInfo, !item.message.media.isEmpty && !(item.message.media.first is TelegramMediaAction) { needShareButton = true } if !needShareButton { loop: for media in item.message.media { if media is TelegramMediaGame || media is TelegramMediaInvoice { needShareButton = true break loop } else if let media = media as? TelegramMediaWebpage, case .Loaded = media.content { needShareButton = true break loop } } } else { loop: for media in item.message.media { if media is TelegramMediaAction { needShareButton = false break loop } } } } var tmpWidth: CGFloat if allowFullWidth { tmpWidth = baseWidth if needShareButton { tmpWidth -= 38.0 } } else { tmpWidth = layoutConstants.bubble.maximumWidthFill.widthFor(baseWidth) if needShareButton && tmpWidth + 32.0 > baseWidth { tmpWidth = baseWidth - 32.0 } } let maximumContentWidth = floor(tmpWidth - layoutConstants.bubble.edgeInset - layoutConstants.bubble.edgeInset - layoutConstants.bubble.contentInsets.left - layoutConstants.bubble.contentInsets.right - avatarInset) var contentPropertiesAndPrepareLayouts: [(Message, Bool, (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))))] = [] var addedContentNodes: [(Message, ChatMessageBubbleContentNode)]? let contentNodeMessagesAndClasses = contentNodeMessagesAndClassesForItem(item) for (contentNodeMessage, contentNodeClass) in contentNodeMessagesAndClasses { var found = false for (currentMessage, currentClass, supportsMosaic, currentLayout) in currentContentClassesPropertiesAndLayouts { if currentClass == contentNodeClass && currentMessage.stableId == contentNodeMessage.stableId { contentPropertiesAndPrepareLayouts.append((contentNodeMessage, supportsMosaic, currentLayout)) found = true break } } if !found { let contentNode = (contentNodeClass as! ChatMessageBubbleContentNode.Type).init() contentPropertiesAndPrepareLayouts.append((contentNodeMessage, contentNode.supportsMosaic, contentNode.asyncLayoutContent())) if addedContentNodes == nil { addedContentNodes = [] } addedContentNodes!.append((contentNodeMessage, contentNode)) } } var authorNameString: String? let authorIsAdmin: Bool switch content { case let .message(_, _, _, isAdmin): authorIsAdmin = isAdmin case .group: authorIsAdmin = false } var inlineBotNameString: String? var replyMessage: Message? var replyMarkup: ReplyMarkupMessageAttribute? var authorNameColor: UIColor? for attribute in firstMessage.attributes { if let attribute = attribute as? InlineBotMessageAttribute { if let peerId = attribute.peerId, let bot = firstMessage.peers[peerId] as? TelegramUser { inlineBotNameString = bot.username } else { inlineBotNameString = attribute.title } } else if let attribute = attribute as? ReplyMessageAttribute { replyMessage = firstMessage.associatedMessages[attribute.messageId] } else if let attribute = attribute as? ReplyMarkupMessageAttribute, attribute.flags.contains(.inline), !attribute.rows.isEmpty { replyMarkup = attribute } } var contentPropertiesAndLayouts: [(CGSize?, ChatMessageBubbleContentProperties, ChatMessageBubblePreparePosition, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void)))] = [] let topNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedTop.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) let bottomNodeMergeStatus: ChatMessageBubbleMergeStatus = mergedBottom.merged ? (incoming ? .Left : .Right) : .None(incoming ? .Incoming : .Outgoing) var backgroundHiding: ChatMessageBubbleContentBackgroundHiding? var hasSolidWallpaper = false if case .color = item.presentationData.theme.wallpaper { hasSolidWallpaper = true } var alignment: ChatMessageBubbleContentAlignment = .none var maximumNodeWidth = maximumContentWidth let contentNodeCount = contentPropertiesAndPrepareLayouts.count let read: Bool switch item.content { case let .message(_, value, _, _): read = value case let .group(messages): read = messages[0].1 } var mosaicStartIndex: Int? var mosaicRange: Range? for i in 0 ..< contentPropertiesAndPrepareLayouts.count { if contentPropertiesAndPrepareLayouts[i].1 { if mosaicStartIndex == nil { mosaicStartIndex = i } } else if let mosaicStartIndexValue = mosaicStartIndex { if mosaicStartIndexValue < i - 1 { mosaicRange = mosaicStartIndexValue ..< i } mosaicStartIndex = nil } } if let mosaicStartIndex = mosaicStartIndex { if mosaicStartIndex < contentPropertiesAndPrepareLayouts.count - 1 { mosaicRange = mosaicStartIndex ..< contentPropertiesAndPrepareLayouts.count } } var index = 0 for (message, _, prepareLayout) in contentPropertiesAndPrepareLayouts { let topPosition: ChatMessageBubbleRelativePosition let bottomPosition: ChatMessageBubbleRelativePosition topPosition = .Neighbour bottomPosition = .Neighbour let prepareContentPosition: ChatMessageBubblePreparePosition if let mosaicRange = mosaicRange, mosaicRange.contains(index) { prepareContentPosition = .mosaic(top: .None(.None(.Incoming)), bottom: index == (mosaicRange.upperBound - 1) ? bottomPosition : .None(.None(.Incoming))) } else { let refinedBottomPosition: ChatMessageBubbleRelativePosition if index == contentPropertiesAndPrepareLayouts.count - 1 { refinedBottomPosition = .None(.Left) } else { refinedBottomPosition = bottomPosition } prepareContentPosition = .linear(top: topPosition, bottom: refinedBottomPosition) } let contentItem = ChatMessageBubbleContentItem(account: item.account, controllerInteraction: item.controllerInteraction, message: message, read: read, presentationData: item.presentationData, associatedData: item.associatedData) var itemSelection: Bool? if case .mosaic = prepareContentPosition { switch content { case .message: break case let .group(messages): for (m, _, selection, _) in messages { if m.id == message.id { switch selection { case .none: break case let .selectable(selected): itemSelection = selected } break } } } } let (properties, unboundSize, maxNodeWidth, nodeLayout) = prepareLayout(contentItem, layoutConstants, prepareContentPosition, itemSelection, CGSize(width: maximumContentWidth, height: CGFloat.greatestFiniteMagnitude)) maximumNodeWidth = min(maximumNodeWidth, maxNodeWidth) contentPropertiesAndLayouts.append((unboundSize, properties, prepareContentPosition, nodeLayout)) switch properties.hidesBackground { case .never: backgroundHiding = .never case .emptyWallpaper: if backgroundHiding == nil { backgroundHiding = properties.hidesBackground } case .always: backgroundHiding = .always } switch properties.forceAlignment { case .none: break case .center: alignment = .center } index += 1 } var initialDisplayHeader = true if let backgroundHiding = backgroundHiding, case .always = backgroundHiding { initialDisplayHeader = false } else { if inlineBotNameString == nil && (ignoreForward || firstMessage.forwardInfo == nil) && replyMessage == nil { if let first = contentPropertiesAndLayouts.first, first.1.hidesSimpleAuthorHeader { initialDisplayHeader = false } } } if initialDisplayHeader && displayAuthorInfo { if let peer = firstMessage.peers[firstMessage.id.peerId] as? TelegramChannel, case .broadcast = peer.info { authorNameString = peer.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) authorNameColor = chatMessagePeerIdColors[Int(peer.id.id % 7)] } else if let effectiveAuthor = effectiveAuthor { authorNameString = effectiveAuthor.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) authorNameColor = chatMessagePeerIdColors[Int(effectiveAuthor.id.id % 7)] } if let rawAuthorNameColor = authorNameColor { var dimColors = false switch item.presentationData.theme.theme.name { case .builtin(.nightAccent), .builtin(.nightGrayscale): dimColors = true default: break } if dimColors { var hue: CGFloat = 0.0 var saturation: CGFloat = 0.0 var brightness: CGFloat = 0.0 rawAuthorNameColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) authorNameColor = UIColor(hue: hue, saturation: saturation * 0.7, brightness: min(1.0, brightness * 1.2), alpha: 1.0) } } } var displayHeader = false if initialDisplayHeader { if authorNameString != nil { displayHeader = true } if inlineBotNameString != nil { displayHeader = true } if firstMessage.forwardInfo != nil { displayHeader = true } if replyMessage != nil { displayHeader = true } } let firstNodeTopPosition: ChatMessageBubbleRelativePosition if displayHeader { firstNodeTopPosition = .Neighbour } else { firstNodeTopPosition = .None(topNodeMergeStatus) } let lastNodeTopPosition: ChatMessageBubbleRelativePosition = .None(bottomNodeMergeStatus) var calculatedGroupFramesAndSize: ([(CGRect, MosaicItemPosition)], CGSize)? var mosaicStatusSizeAndApply: (CGSize, (Bool) -> ChatMessageDateAndStatusNode)? if let mosaicRange = mosaicRange { let maxSize = layoutConstants.image.maxDimensions.fittedToWidthOrSmaller(maximumContentWidth - layoutConstants.image.bubbleInsets.left - layoutConstants.image.bubbleInsets.right) let (innerFramesAndPositions, innerSize) = chatMessageBubbleMosaicLayout(maxSize: maxSize, itemSizes: contentPropertiesAndLayouts[mosaicRange].map { $0.0 ?? CGSize(width: 256.0, height: 256.0) }) let framesAndPositions = innerFramesAndPositions.map { ($0.0.offsetBy(dx: layoutConstants.image.bubbleInsets.left, dy: layoutConstants.image.bubbleInsets.top), $0.1) } let size = CGSize(width: innerSize.width + layoutConstants.image.bubbleInsets.left + layoutConstants.image.bubbleInsets.right, height: innerSize.height + layoutConstants.image.bubbleInsets.top + layoutConstants.image.bubbleInsets.bottom) calculatedGroupFramesAndSize = (framesAndPositions, size) maximumNodeWidth = size.width if mosaicRange.upperBound == contentPropertiesAndLayouts.count { let message = item.content.firstMessage var edited = false var sentViaBot = false var viewCount: Int? for attribute in message.attributes { if let _ = attribute as? EditedMessageAttribute { edited = true } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count } else if let _ = attribute as? InlineBotMessageAttribute { sentViaBot = true } } if let author = message.author as? TelegramUser, author.botInfo != nil { sentViaBot = true } let dateText = stringForMessageTimestampStatus(message: message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings) let statusType: ChatMessageDateAndStatusType if message.effectivelyIncoming(item.account.peerId) { statusType = .ImageIncoming } else { if message.flags.contains(.Failed) { statusType = .ImageOutgoing(.Failed) } else if message.flags.isSending && !message.isSentOrAcknowledged { statusType = .ImageOutgoing(.Sending) } else { statusType = .ImageOutgoing(.Sent(read: item.read)) } } mosaicStatusSizeAndApply = mosaicStatusLayout(item.presentationData.theme, item.presentationData.strings, edited && !sentViaBot, viewCount, dateText, statusType, CGSize(width: 200.0, height: CGFloat.greatestFiniteMagnitude)) } } var headerSize = CGSize() var nameNodeOriginY: CGFloat = 0.0 var nameNodeSizeApply: (CGSize, () -> TextNode?) = (CGSize(), { nil }) var adminNodeSizeApply: (CGSize, () -> TextNode?) = (CGSize(), { nil }) var replyInfoOriginY: CGFloat = 0.0 var replyInfoSizeApply: (CGSize, () -> ChatMessageReplyInfoNode?) = (CGSize(), { nil }) var forwardInfoOriginY: CGFloat = 0.0 var forwardInfoSizeApply: (CGSize, () -> ChatMessageForwardInfoNode?) = (CGSize(), { nil }) if displayHeader { if authorNameString != nil || inlineBotNameString != nil { if headerSize.height.isZero { headerSize.height += 5.0 } let inlineBotNameColor = incoming ? item.presentationData.theme.theme.chat.bubble.incomingAccentTextColor : item.presentationData.theme.theme.chat.bubble.outgoingAccentTextColor let attributedString: NSAttributedString var adminBadgeString: NSAttributedString? if authorIsAdmin { adminBadgeString = NSAttributedString(string: " \(item.presentationData.strings.Conversation_Admin)", font: inlineBotPrefixFont, textColor: incoming ? item.presentationData.theme.theme.chat.bubble.incomingSecondaryTextColor : item.presentationData.theme.theme.chat.bubble.outgoingSecondaryTextColor) } if let authorNameString = authorNameString, let authorNameColor = authorNameColor, let inlineBotNameString = inlineBotNameString { let mutableString = NSMutableAttributedString(string: "\(authorNameString) ", attributes: [NSAttributedStringKey.font: nameFont, NSAttributedStringKey.foregroundColor: authorNameColor]) let bodyAttributes = MarkdownAttributeSet(font: nameFont, textColor: inlineBotNameColor) let boldAttributes = MarkdownAttributeSet(font: inlineBotPrefixFont, textColor: inlineBotNameColor) let botString = addAttributesToStringWithRanges(item.presentationData.strings.Conversation_MessageViaUser("@\(inlineBotNameString)"), body: bodyAttributes, argumentAttributes: [0: boldAttributes]) mutableString.append(botString) attributedString = mutableString } else if let authorNameString = authorNameString, let authorNameColor = authorNameColor { attributedString = NSAttributedString(string: authorNameString, font: nameFont, textColor: authorNameColor) } else if let inlineBotNameString = inlineBotNameString { let bodyAttributes = MarkdownAttributeSet(font: inlineBotPrefixFont, textColor: inlineBotNameColor) let boldAttributes = MarkdownAttributeSet(font: nameFont, textColor: inlineBotNameColor) attributedString = addAttributesToStringWithRanges(item.presentationData.strings.Conversation_MessageViaUser("@\(inlineBotNameString)"), body: bodyAttributes, argumentAttributes: [0: boldAttributes]) } else { attributedString = NSAttributedString(string: "", font: nameFont, textColor: inlineBotNameColor) } let adminBadgeSizeAndApply = adminBadgeLayout(TextNodeLayoutArguments(attributedString: adminBadgeString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) adminNodeSizeApply = (adminBadgeSizeAndApply.0.size, { return adminBadgeSizeAndApply.1() }) let sizeAndApply = authorNameLayout(TextNodeLayoutArguments(attributedString: attributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: max(0, maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right - adminBadgeSizeAndApply.0.size.width), height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) nameNodeSizeApply = (sizeAndApply.0.size, { return sizeAndApply.1() }) nameNodeOriginY = headerSize.height headerSize.width = max(headerSize.width, nameNodeSizeApply.0.width + adminBadgeSizeAndApply.0.size.width + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right) headerSize.height += nameNodeSizeApply.0.height } if !ignoreForward, let forwardInfo = firstMessage.forwardInfo { if headerSize.height.isZero { headerSize.height += 5.0 } let forwardSource: Peer let forwardAuthorSignature: String? if let source = forwardInfo.source { forwardSource = source if let authorSignature = forwardInfo.authorSignature { forwardAuthorSignature = authorSignature } else if forwardInfo.author.id != source.id { forwardAuthorSignature = forwardInfo.author.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } else { forwardAuthorSignature = nil } } else { forwardSource = forwardInfo.author forwardAuthorSignature = nil } let sizeAndApply = forwardInfoLayout(item.presentationData, item.presentationData.strings, .bubble(incoming: incoming), forwardSource, forwardAuthorSignature, CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude)) forwardInfoSizeApply = (sizeAndApply.0, { sizeAndApply.1() }) forwardInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, forwardInfoSizeApply.0.width + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right) headerSize.height += forwardInfoSizeApply.0.height } if let replyMessage = replyMessage { if headerSize.height.isZero { headerSize.height += 6.0 } else { headerSize.height += 2.0 } let sizeAndApply = replyInfoLayout(item.presentationData, item.presentationData.strings, item.account, .bubble(incoming: incoming), replyMessage, CGSize(width: maximumNodeWidth - layoutConstants.text.bubbleInsets.left - layoutConstants.text.bubbleInsets.right, height: CGFloat.greatestFiniteMagnitude)) replyInfoSizeApply = (sizeAndApply.0, { sizeAndApply.1() }) replyInfoOriginY = headerSize.height headerSize.width = max(headerSize.width, replyInfoSizeApply.0.width + layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right) headerSize.height += replyInfoSizeApply.0.height + 2.0 } if !headerSize.height.isZero { headerSize.height -= 5.0 } } let hideBackground: Bool if let backgroundHiding = backgroundHiding { switch backgroundHiding { case .never: hideBackground = false case .emptyWallpaper: hideBackground = hasSolidWallpaper && !displayHeader case .always: hideBackground = true } } else { hideBackground = false } var removedContentNodeIndices: [Int]? findRemoved: for i in 0 ..< currentContentClassesPropertiesAndLayouts.count { let currentMessage = currentContentClassesPropertiesAndLayouts[i].0 let currentClass: AnyClass = currentContentClassesPropertiesAndLayouts[i].1 for (contentNodeMessage, contentNodeClass) in contentNodeMessagesAndClasses { if currentClass == contentNodeClass && currentMessage.stableId == contentNodeMessage.stableId { continue findRemoved } } if removedContentNodeIndices == nil { removedContentNodeIndices = [i] } else { removedContentNodeIndices!.append(i) } } var contentNodePropertiesAndFinalize: [(ChatMessageBubbleContentProperties, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool) -> Void))] = [] var maxContentWidth: CGFloat = headerSize.width var actionButtonsFinalize: ((CGFloat) -> (CGSize, (_ animated: Bool) -> ChatMessageActionButtonsNode))? if let replyMarkup = replyMarkup { let (minWidth, buttonsLayout) = actionButtonsLayout(item.account, item.presentationData.theme, item.presentationData.strings, replyMarkup, item.message, maximumNodeWidth) maxContentWidth = max(maxContentWidth, minWidth) actionButtonsFinalize = buttonsLayout } for i in 0 ..< contentPropertiesAndLayouts.count { let (_, contentNodeProperties, preparePosition, contentNodeLayout) = contentPropertiesAndLayouts[i] if let mosaicRange = mosaicRange, mosaicRange.contains(i), let (framesAndPositions, size) = calculatedGroupFramesAndSize { let mosaicIndex = i - mosaicRange.lowerBound let position = framesAndPositions[mosaicIndex].1 let topLeft: ChatMessageBubbleContentMosaicNeighbor let topRight: ChatMessageBubbleContentMosaicNeighbor let bottomLeft: ChatMessageBubbleContentMosaicNeighbor let bottomRight: ChatMessageBubbleContentMosaicNeighbor switch firstNodeTopPosition { case .Neighbour: topLeft = .merged topRight = .merged case let .None(status): if position.contains(.top) && position.contains(.left) { switch status { case .Left: topLeft = .merged case .Right: topLeft = .none(tail: false) case .None: topLeft = .none(tail: false) } } else { topLeft = .merged } if position.contains(.top) && position.contains(.right) { switch status { case .Left: topRight = .none(tail: false) case .Right: topRight = .merged case .None: topRight = .none(tail: false) } } else { topRight = .merged } } let lastMosaicBottomPosition: ChatMessageBubbleRelativePosition if mosaicRange.upperBound - 1 == contentNodeCount - 1 { lastMosaicBottomPosition = lastNodeTopPosition } else { lastMosaicBottomPosition = .Neighbour } if position.contains(.bottom), case .Neighbour = lastMosaicBottomPosition { bottomLeft = .merged bottomRight = .merged } else { switch lastNodeTopPosition { case .Neighbour: bottomLeft = .merged bottomRight = .merged case let .None(status): if position.contains(.bottom) && position.contains(.left) { switch status { case .Left: bottomLeft = .merged case .Right: bottomLeft = .none(tail: false) case let .None(tailStatus): if case .Incoming = tailStatus { bottomLeft = .none(tail: true) } else { bottomLeft = .none(tail: false) } } } else { bottomLeft = .merged } if position.contains(.bottom) && position.contains(.right) { switch status { case .Left: bottomRight = .none(tail: false) case .Right: bottomRight = .merged case let .None(tailStatus): if case .Outgoing = tailStatus { bottomRight = .none(tail: true) } else { bottomRight = .none(tail: false) } } } else { bottomRight = .merged } } } let (_, contentNodeFinalize) = contentNodeLayout(framesAndPositions[mosaicIndex].0.size, .mosaic(position: ChatMessageBubbleContentMosaicPosition(topLeft: topLeft, topRight: topRight, bottomLeft: bottomLeft, bottomRight: bottomRight))) contentNodePropertiesAndFinalize.append((contentNodeProperties, contentNodeFinalize)) maxContentWidth = max(maxContentWidth, size.width) } else { let contentPosition: ChatMessageBubbleContentPosition switch preparePosition { case .linear: let topPosition: ChatMessageBubbleRelativePosition let bottomPosition: ChatMessageBubbleRelativePosition if i == 0 { topPosition = firstNodeTopPosition } else { topPosition = .Neighbour } if i == contentNodeCount - 1 { bottomPosition = lastNodeTopPosition } else { bottomPosition = .Neighbour } contentPosition = .linear(top: topPosition, bottom: bottomPosition) case .mosaic: assertionFailure() contentPosition = .linear(top: .Neighbour, bottom: .Neighbour) } let (contentNodeWidth, contentNodeFinalize) = contentNodeLayout(CGSize(width: maximumNodeWidth, height: CGFloat.greatestFiniteMagnitude), contentPosition) #if DEBUG if contentNodeWidth > maximumNodeWidth { print("contentNodeWidth \(contentNodeWidth) > \(maximumNodeWidth)") } #endif maxContentWidth = max(maxContentWidth, contentNodeWidth) contentNodePropertiesAndFinalize.append((contentNodeProperties, contentNodeFinalize)) } } var contentSize = CGSize(width: maxContentWidth, height: 0.0) var contentNodeFramesPropertiesAndApply: [(CGRect, ChatMessageBubbleContentProperties, (ListViewItemUpdateAnimation, Bool) -> Void)] = [] var contentNodesHeight: CGFloat = 0.0 var mosaicStatusOrigin: CGPoint? for i in 0 ..< contentNodePropertiesAndFinalize.count { let (properties, finalize) = contentNodePropertiesAndFinalize[i] if let mosaicRange = mosaicRange, mosaicRange.contains(i), let (framesAndPositions, size) = calculatedGroupFramesAndSize { let mosaicIndex = i - mosaicRange.lowerBound if mosaicIndex == 0 { if !headerSize.height.isZero { contentNodesHeight += 7.0 } } let (_, apply) = finalize(maxContentWidth) let contentNodeFrame = framesAndPositions[mosaicIndex].0.offsetBy(dx: 0.0, dy: contentNodesHeight) contentNodeFramesPropertiesAndApply.append((contentNodeFrame, properties, apply)) if mosaicIndex == mosaicRange.upperBound - 1 { contentNodesHeight += size.height mosaicStatusOrigin = contentNodeFrame.bottomRight } } else { if i == 0 && !headerSize.height.isZero { contentNodesHeight += properties.headerSpacing } let (size, apply) = finalize(maxContentWidth) contentNodeFramesPropertiesAndApply.append((CGRect(origin: CGPoint(x: 0.0, y: contentNodesHeight), size: size), properties, apply)) contentNodesHeight += size.height } } contentSize.height += contentNodesHeight var actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)? if let actionButtonsFinalize = actionButtonsFinalize { actionButtonsSizeAndApply = actionButtonsFinalize(maxContentWidth) } let minimalContentSize: CGSize if hideBackground { minimalContentSize = CGSize(width: 1.0, height: 1.0) } else { minimalContentSize = layoutConstants.bubble.minimumSize } let calculatedBubbleHeight = headerSize.height + contentSize.height + layoutConstants.bubble.contentInsets.top + layoutConstants.bubble.contentInsets.bottom let layoutBubbleSize = CGSize(width: max(contentSize.width, headerSize.width) + layoutConstants.bubble.contentInsets.left + layoutConstants.bubble.contentInsets.right, height: max(minimalContentSize.height, calculatedBubbleHeight)) var contentVerticalOffset: CGFloat = 0.0 if minimalContentSize.height > calculatedBubbleHeight + 2.0 { contentVerticalOffset = floorToScreenPixels((minimalContentSize.height - calculatedBubbleHeight) / 2.0) } var deliveryFailedInset: CGFloat = 0.0 if item.content.firstMessage.flags.contains(.Failed) { deliveryFailedInset += 24.0 } let backgroundFrame: CGRect let contentOrigin: CGPoint let contentUpperRightCorner: CGPoint switch alignment { case .none: backgroundFrame = CGRect(origin: CGPoint(x: incoming ? (params.leftInset + layoutConstants.bubble.edgeInset + avatarInset) : (params.width - params.rightInset - layoutBubbleSize.width - layoutConstants.bubble.edgeInset - deliveryFailedInset), y: 0.0), size: layoutBubbleSize) contentOrigin = CGPoint(x: backgroundFrame.origin.x + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height + contentVerticalOffset) contentUpperRightCorner = CGPoint(x: backgroundFrame.maxX - (incoming ? layoutConstants.bubble.contentInsets.right : layoutConstants.bubble.contentInsets.left), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height) case .center: let availableWidth = params.width - params.leftInset - params.rightInset backgroundFrame = CGRect(origin: CGPoint(x: params.leftInset + floor((availableWidth - layoutBubbleSize.width) / 2.0), y: 0.0), size: layoutBubbleSize) contentOrigin = CGPoint(x: backgroundFrame.minX + floor(layoutConstants.bubble.contentInsets.right + layoutConstants.bubble.contentInsets.left) / 2.0, y: backgroundFrame.minY + layoutConstants.bubble.contentInsets.top + headerSize.height + contentVerticalOffset) contentUpperRightCorner = CGPoint(x: backgroundFrame.maxX - (incoming ? layoutConstants.bubble.contentInsets.right : layoutConstants.bubble.contentInsets.left), y: backgroundFrame.origin.y + layoutConstants.bubble.contentInsets.top + headerSize.height) } var layoutSize = CGSize(width: params.width, height: layoutBubbleSize.height) if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { layoutSize.height += actionButtonsSizeAndApply.0.height } var layoutInsets = UIEdgeInsets(top: mergedTop.merged ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, left: 0.0, bottom: mergedBottom.merged ? layoutConstants.bubble.mergedSpacing : layoutConstants.bubble.defaultSpacing, right: 0.0) if dateHeaderAtBottom { layoutInsets.top += layoutConstants.timestampHeaderHeight } var updatedShareButtonBackground: UIImage? var updatedShareButtonNode: HighlightableButtonNode? if needShareButton { if currentShareButtonNode != nil { updatedShareButtonNode = currentShareButtonNode if item.presentationData.theme !== currentItem?.presentationData.theme { let graphics = PresentationResourcesChat.additionalGraphics(item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) if item.message.id.peerId == item.account.peerId { updatedShareButtonBackground = graphics.chatBubbleNavigateButtonImage } else { updatedShareButtonBackground = graphics.chatBubbleShareButtonImage } } } else { let buttonNode = HighlightableButtonNode() let buttonIcon: UIImage? let graphics = PresentationResourcesChat.additionalGraphics(item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) if item.message.id.peerId == item.account.peerId { buttonIcon = graphics.chatBubbleNavigateButtonImage } else { buttonIcon = graphics.chatBubbleShareButtonImage } buttonNode.setBackgroundImage(buttonIcon, for: [.normal]) updatedShareButtonNode = buttonNode } } let layout = ListViewItemNodeLayout(contentSize: layoutSize, insets: layoutInsets) let graphics = PresentationResourcesChat.principalGraphics(item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) var updatedMergedTop = mergedBottom var updatedMergedBottom = mergedTop if mosaicRange == nil { if contentNodePropertiesAndFinalize.first?.0.forceFullCorners ?? false { updatedMergedTop = .semanticallyMerged } if headerSize.height.isZero && contentNodePropertiesAndFinalize.first?.0.forceFullCorners ?? false { updatedMergedBottom = .none } if actionButtonsSizeAndApply != nil { updatedMergedTop = .fullyMerged } } return (layout, { [weak self] animation, synchronousLoads in if let strongSelf = self { strongSelf.appliedItem = item var transition: ContainedViewLayoutTransition = .immediate if case let .System(duration) = animation { transition = .animated(duration: duration, curve: .spring) } var forceBackgroundSide = false if actionButtonsSizeAndApply != nil { forceBackgroundSide = true } else if case .semanticallyMerged = updatedMergedTop { forceBackgroundSide = true } let mergeType = ChatMessageBackgroundMergeType(top: updatedMergedTop == .fullyMerged, bottom: updatedMergedBottom == .fullyMerged, side: forceBackgroundSide) let backgroundType: ChatMessageBackgroundType if hideBackground { backgroundType = .none } else if !incoming { backgroundType = .outgoing(mergeType) } else { backgroundType = .incoming(mergeType) } strongSelf.backgroundNode.setType(type: backgroundType, highlighted: strongSelf.highlightedState, graphics: graphics, transition: transition) strongSelf.backgroundType = backgroundType if item.content.firstMessage.flags.contains(.Failed) { let deliveryFailedNode: ChatMessageDeliveryFailedNode var isAppearing = false if let current = strongSelf.deliveryFailedNode { deliveryFailedNode = current } else { isAppearing = true deliveryFailedNode = ChatMessageDeliveryFailedNode(tapped: { if let item = self?.item { item.controllerInteraction.requestRedeliveryOfFailedMessages(item.content.firstMessage.id) } }) strongSelf.deliveryFailedNode = deliveryFailedNode strongSelf.addSubnode(deliveryFailedNode) } let deliveryFailedSize = deliveryFailedNode.updateLayout(theme: item.presentationData.theme.theme) let deliveryFailedFrame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + deliveryFailedInset - deliveryFailedSize.width, y: backgroundFrame.maxY - deliveryFailedSize.height), size: deliveryFailedSize) if isAppearing { deliveryFailedNode.frame = deliveryFailedFrame transition.animatePositionAdditive(node: deliveryFailedNode, offset: CGPoint(x: deliveryFailedInset, y: 0.0)) } else { transition.updateFrame(node: deliveryFailedNode, frame: deliveryFailedFrame) } } else if let deliveryFailedNode = strongSelf.deliveryFailedNode { strongSelf.deliveryFailedNode = nil transition.updateAlpha(node: deliveryFailedNode, alpha: 0.0) transition.updateFrame(node: deliveryFailedNode, frame: deliveryFailedNode.frame.offsetBy(dx: 24.0, dy: 0.0), completion: { [weak deliveryFailedNode] _ in deliveryFailedNode?.removeFromSupernode() }) } if let nameNode = nameNodeSizeApply.1() { strongSelf.nameNode = nameNode if nameNode.supernode == nil { if !nameNode.isNodeLoaded { nameNode.isUserInteractionEnabled = false } strongSelf.addSubnode(nameNode) } nameNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: nameNodeSizeApply.0) if let adminBadgeNode = adminNodeSizeApply.1() { strongSelf.adminBadgeNode = adminBadgeNode let adminBadgeFrame = CGRect(origin: CGPoint(x: contentUpperRightCorner.x - layoutConstants.text.bubbleInsets.left - adminNodeSizeApply.0.width, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: adminNodeSizeApply.0) if adminBadgeNode.supernode == nil { if !adminBadgeNode.isNodeLoaded { adminBadgeNode.isUserInteractionEnabled = false } strongSelf.addSubnode(adminBadgeNode) adminBadgeNode.frame = adminBadgeFrame } else { let previousAdminBadgeFrame = adminBadgeNode.frame adminBadgeNode.frame = adminBadgeFrame transition.animatePositionAdditive(node: adminBadgeNode, offset: CGPoint(x: previousAdminBadgeFrame.maxX - adminBadgeFrame.maxX, y: 0.0)) } } else { strongSelf.adminBadgeNode?.removeFromSupernode() strongSelf.adminBadgeNode = nil } } else { strongSelf.nameNode?.removeFromSupernode() strongSelf.nameNode = nil strongSelf.adminBadgeNode?.removeFromSupernode() strongSelf.adminBadgeNode = nil } if let forwardInfoNode = forwardInfoSizeApply.1() { strongSelf.forwardInfoNode = forwardInfoNode var animateFrame = true if forwardInfoNode.supernode == nil { strongSelf.addSubnode(forwardInfoNode) animateFrame = false } let previousForwardInfoNodeFrame = forwardInfoNode.frame forwardInfoNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + forwardInfoOriginY), size: forwardInfoSizeApply.0) if case let .System(duration) = animation { if animateFrame { forwardInfoNode.layer.animateFrame(from: previousForwardInfoNodeFrame, to: forwardInfoNode.frame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } } else { strongSelf.forwardInfoNode?.removeFromSupernode() strongSelf.forwardInfoNode = nil } if let replyInfoNode = replyInfoSizeApply.1() { strongSelf.replyInfoNode = replyInfoNode var animateFrame = true if replyInfoNode.supernode == nil { strongSelf.addSubnode(replyInfoNode) animateFrame = false } let previousReplyInfoNodeFrame = replyInfoNode.frame replyInfoNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + replyInfoOriginY), size: replyInfoSizeApply.0) if case let .System(duration) = animation { if animateFrame { replyInfoNode.layer.animateFrame(from: previousReplyInfoNodeFrame, to: replyInfoNode.frame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } } else { strongSelf.replyInfoNode?.removeFromSupernode() strongSelf.replyInfoNode = nil } if removedContentNodeIndices?.count ?? 0 != 0 || addedContentNodes?.count ?? 0 != 0 { var updatedContentNodes = strongSelf.contentNodes if let removedContentNodeIndices = removedContentNodeIndices { for index in removedContentNodeIndices.reversed() { let node = updatedContentNodes[index] if animation.isAnimated { node.animateRemovalFromBubble(0.2, completion: { [weak node] in node?.removeFromSupernode() }) } else { node.removeFromSupernode() } let _ = updatedContentNodes.remove(at: index) } } if let addedContentNodes = addedContentNodes { for (contentNodeMessage, contentNode) in addedContentNodes { updatedContentNodes.append(contentNode) strongSelf.addSubnode(contentNode) contentNode.visibility = strongSelf.visibility } } var sortedContentNodes: [ChatMessageBubbleContentNode] = [] outer: for (message, nodeClass) in contentNodeMessagesAndClasses { if let addedContentNodes = addedContentNodes { for (contentNodeMessage, contentNode) in addedContentNodes { if type(of: contentNode) == nodeClass && contentNodeMessage.stableId == message.stableId { sortedContentNodes.append(contentNode) continue outer } } } for contentNode in updatedContentNodes { if type(of: contentNode) == nodeClass && contentNode.item?.message.stableId == message.stableId { sortedContentNodes.append(contentNode) continue outer } } } assert(sortedContentNodes.count == updatedContentNodes.count) strongSelf.contentNodes = sortedContentNodes } var contentNodeIndex = 0 for (relativeFrame, _, apply) in contentNodeFramesPropertiesAndApply { apply(animation, synchronousLoads) let contentNode = strongSelf.contentNodes[contentNodeIndex] let contentNodeFrame = relativeFrame.offsetBy(dx: contentOrigin.x, dy: contentOrigin.y) let previousContentNodeFrame = contentNode.frame contentNode.frame = contentNodeFrame if case let .System(duration) = animation { var animateFrame = false var animateAlpha = false if let addedContentNodes = addedContentNodes { if !addedContentNodes.contains(where: { $0.1 === contentNode }) { animateFrame = true } else { animateAlpha = true } } else { animateFrame = true } if animateFrame { contentNode.layer.animateFrame(from: previousContentNodeFrame, to: contentNodeFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } else if animateAlpha { contentNode.animateInsertionIntoBubble(duration) var previousAlignedContentNodeFrame = contentNodeFrame previousAlignedContentNodeFrame.origin.x += backgroundFrame.size.width - strongSelf.backgroundNode.frame.size.width contentNode.layer.animateFrame(from: previousAlignedContentNodeFrame, to: contentNodeFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } contentNodeIndex += 1 } if let mosaicStatusOrigin = mosaicStatusOrigin, let (size, apply) = mosaicStatusSizeAndApply { let mosaicStatusNode = apply(false) if mosaicStatusNode !== strongSelf.mosaicStatusNode { strongSelf.mosaicStatusNode?.removeFromSupernode() strongSelf.mosaicStatusNode = mosaicStatusNode strongSelf.addSubnode(mosaicStatusNode) } let absoluteOrigin = mosaicStatusOrigin.offsetBy(dx: contentOrigin.x, dy: contentOrigin.y) mosaicStatusNode.frame = CGRect(origin: CGPoint(x: absoluteOrigin.x - layoutConstants.image.statusInsets.right - size.width, y: absoluteOrigin.y - layoutConstants.image.statusInsets.bottom - size.height), size: size) } else if let mosaicStatusNode = strongSelf.mosaicStatusNode { strongSelf.mosaicStatusNode = nil mosaicStatusNode.removeFromSupernode() } if let updatedShareButtonNode = updatedShareButtonNode { if updatedShareButtonNode !== strongSelf.shareButtonNode { if let shareButtonNode = strongSelf.shareButtonNode { shareButtonNode.removeFromSupernode() } strongSelf.shareButtonNode = updatedShareButtonNode strongSelf.addSubnode(updatedShareButtonNode) updatedShareButtonNode.addTarget(strongSelf, action: #selector(strongSelf.shareButtonPressed), forControlEvents: .touchUpInside) } if let updatedShareButtonBackground = updatedShareButtonBackground { strongSelf.shareButtonNode?.setBackgroundImage(updatedShareButtonBackground, for: [.normal]) } } else if let shareButtonNode = strongSelf.shareButtonNode { shareButtonNode.removeFromSupernode() strongSelf.shareButtonNode = nil } if case .System = animation { if !strongSelf.backgroundNode.frame.equalTo(backgroundFrame) { strongSelf.backgroundFrameTransition = (strongSelf.backgroundNode.frame, backgroundFrame) strongSelf.enableTransitionClippingNode() } if let shareButtonNode = strongSelf.shareButtonNode { let currentBackgroundFrame = strongSelf.backgroundNode.frame shareButtonNode.frame = CGRect(origin: CGPoint(x: currentBackgroundFrame.maxX + 8.0, y: currentBackgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) } } else { if let _ = strongSelf.backgroundFrameTransition { strongSelf.animateFrameTransition(1.0, backgroundFrame.size.height) strongSelf.backgroundFrameTransition = nil } strongSelf.backgroundNode.frame = backgroundFrame if let shareButtonNode = strongSelf.shareButtonNode { shareButtonNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) } strongSelf.disableTransitionClippingNode() } let offset: CGFloat = params.leftInset + (incoming ? 42.0 : 0.0) strongSelf.selectionNode?.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: params.width, height: layout.size.height)) if let actionButtonsSizeAndApply = actionButtonsSizeAndApply { var animated = false if let _ = strongSelf.actionButtonsNode { if case .System = animation { animated = true } } let actionButtonsNode = actionButtonsSizeAndApply.1(animated) let previousFrame = actionButtonsNode.frame let actionButtonsFrame = CGRect(origin: CGPoint(x: backgroundFrame.minX + (incoming ? layoutConstants.bubble.contentInsets.left : layoutConstants.bubble.contentInsets.right), y: backgroundFrame.maxY), size: actionButtonsSizeAndApply.0) actionButtonsNode.frame = actionButtonsFrame if actionButtonsNode !== strongSelf.actionButtonsNode { strongSelf.actionButtonsNode = actionButtonsNode actionButtonsNode.buttonPressed = { button in if let strongSelf = self { strongSelf.performMessageButtonAction(button: button) } } strongSelf.addSubnode(actionButtonsNode) } else { if case let .System(duration) = animation { actionButtonsNode.layer.animateFrame(from: previousFrame, to: actionButtonsFrame, duration: duration, timingFunction: kCAMediaTimingFunctionSpring) } } } else if let actionButtonsNode = strongSelf.actionButtonsNode { actionButtonsNode.removeFromSupernode() strongSelf.actionButtonsNode = nil } } }) } } private func addContentNode(node: ChatMessageBubbleContentNode) { if let transitionClippingNode = self.transitionClippingNode { transitionClippingNode.addSubnode(node) } else { self.addSubnode(node) } } private func enableTransitionClippingNode() { if self.transitionClippingNode == nil { let node = ASDisplayNode() node.clipsToBounds = true var backgroundFrame = self.backgroundNode.frame backgroundFrame = backgroundFrame.insetBy(dx: 0.0, dy: 1.0) node.frame = backgroundFrame node.bounds = CGRect(origin: CGPoint(x: backgroundFrame.origin.x, y: backgroundFrame.origin.y), size: backgroundFrame.size) if let forwardInfoNode = self.forwardInfoNode { node.addSubnode(forwardInfoNode) } if let replyInfoNode = self.replyInfoNode { node.addSubnode(replyInfoNode) } for contentNode in self.contentNodes { node.addSubnode(contentNode) } self.addSubnode(node) self.transitionClippingNode = node } } private func disableTransitionClippingNode() { if let transitionClippingNode = self.transitionClippingNode { if let forwardInfoNode = self.forwardInfoNode { self.addSubnode(forwardInfoNode) } if let replyInfoNode = self.replyInfoNode { self.addSubnode(replyInfoNode) } for contentNode in self.contentNodes { self.addSubnode(contentNode) } transitionClippingNode.removeFromSupernode() self.transitionClippingNode = nil } } override func shouldAnimateHorizontalFrameTransition() -> Bool { if let _ = self.backgroundFrameTransition { return true } else { return false } } override func animateFrameTransition(_ progress: CGFloat, _ currentValue: CGFloat) { super.animateFrameTransition(progress, currentValue) if let backgroundFrameTransition = self.backgroundFrameTransition { let backgroundFrame = CGRect.interpolator()(backgroundFrameTransition.0, backgroundFrameTransition.1, progress) as! CGRect self.backgroundNode.frame = backgroundFrame if let shareButtonNode = self.shareButtonNode { shareButtonNode.frame = CGRect(origin: CGPoint(x: backgroundFrame.maxX + 8.0, y: backgroundFrame.maxY - 30.0), size: CGSize(width: 29.0, height: 29.0)) } if let transitionClippingNode = self.transitionClippingNode { var fixedBackgroundFrame = backgroundFrame fixedBackgroundFrame = fixedBackgroundFrame.insetBy(dx: 0.0, dy: self.backgroundNode.type == ChatMessageBackgroundType.none ? 0.0 : 1.0) transitionClippingNode.frame = fixedBackgroundFrame transitionClippingNode.bounds = CGRect(origin: CGPoint(x: fixedBackgroundFrame.origin.x, y: fixedBackgroundFrame.origin.y), size: fixedBackgroundFrame.size) if progress >= 1.0 - CGFloat.ulpOfOne { self.disableTransitionClippingNode() } } if CGFloat(1.0).isLessThanOrEqualTo(progress) { self.backgroundFrameTransition = nil } } } @objc func tapLongTapOrDoubleTapGesture(_ recognizer: TapLongTapOrDoubleTapGestureRecognizer) { switch recognizer.state { case .ended: if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation { switch gesture { case .tap: if let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(location) { if let item = self.item, let author = item.content.firstMessage.author { let navigate: ChatControllerInteractionNavigateToPeer if item.content.firstMessage.id.peerId == item.account.peerId { navigate = .chat(textInputState: nil, messageId: nil) } else { navigate = .info } item.controllerInteraction.openPeer(item.effectiveAuthorId ?? author.id, navigate, item.message) } return } if let nameNode = self.nameNode, nameNode.frame.contains(location) { if let item = self.item { for attribute in item.message.attributes { if let attribute = attribute as? InlineBotMessageAttribute { var botAddressName: String? if let peerId = attribute.peerId, let botPeer = item.message.peers[peerId], let addressName = botPeer.addressName { botAddressName = addressName } else { botAddressName = attribute.title } if let botAddressName = botAddressName { item.controllerInteraction.updateInputState { textInputState in return ChatTextInputState(inputText: NSAttributedString(string: "@" + botAddressName + " ")) } item.controllerInteraction.updateInputMode { _ in return .text } } return } } } } else if let replyInfoNode = self.replyInfoNode, replyInfoNode.frame.contains(location) { if let item = self.item { for attribute in item.message.attributes { if let attribute = attribute as? ReplyMessageAttribute { item.controllerInteraction.navigateToMessage(item.message.id, attribute.messageId) return } } } } if let forwardInfoNode = self.forwardInfoNode, forwardInfoNode.frame.contains(location) { if let item = self.item, let forwardInfo = item.message.forwardInfo { if let sourceMessageId = forwardInfo.sourceMessageId { item.controllerInteraction.navigateToMessage(item.message.id, sourceMessageId) } else { item.controllerInteraction.openPeer(forwardInfo.source?.id ?? forwardInfo.author.id, .info, nil) } return } } var foundTapAction = false loop: for contentNode in self.contentNodes { let tapAction = contentNode.tapActionAtPoint(CGPoint(x: location.x - contentNode.frame.minX, y: location.y - contentNode.frame.minY), gesture: gesture) switch tapAction { case .none, .ignore: break case let .url(url, concealed): foundTapAction = true self.item?.controllerInteraction.openUrl(url, concealed, nil) break loop case let .peerMention(peerId, _): foundTapAction = true self.item?.controllerInteraction.openPeer(peerId, .chat(textInputState: nil, messageId: nil), nil) break loop case let .textMention(name): foundTapAction = true self.item?.controllerInteraction.openPeerMention(name) break loop case let .botCommand(command): foundTapAction = true if let item = self.item { item.controllerInteraction.sendBotCommand(item.message.id, command) } break loop case let .hashtag(peerName, hashtag): foundTapAction = true self.item?.controllerInteraction.openHashtag(peerName, hashtag) break loop case .instantPage: foundTapAction = true if let item = self.item { item.controllerInteraction.openInstantPage(item.message) } break loop case let .call(peerId): foundTapAction = true self.item?.controllerInteraction.callPeer(peerId) break loop case .openMessage: foundTapAction = true if let item = self.item { let _ = item.controllerInteraction.openMessage(item.message, .default) } break loop } } if !foundTapAction { self.item?.controllerInteraction.clickThroughMessage() } case .longTap, .doubleTap: if let item = self.item, self.backgroundNode.frame.contains(location) { var foundTapAction = false var tapMessage: Message? = item.content.firstMessage var selectAll = false loop: for contentNode in self.contentNodes { if !contentNode.frame.contains(location) { continue loop } else if contentNode is ChatMessageTextBubbleContentNode { selectAll = true } tapMessage = contentNode.item?.message let tapAction = contentNode.tapActionAtPoint(CGPoint(x: location.x - contentNode.frame.minX, y: location.y - contentNode.frame.minY), gesture: gesture) switch tapAction { case .none, .ignore: break case let .url(url, _): foundTapAction = true item.controllerInteraction.longTap(.url(url)) break loop case let .peerMention(peerId, mention): foundTapAction = true item.controllerInteraction.longTap(.peerMention(peerId, mention)) break loop case let .textMention(name): foundTapAction = true item.controllerInteraction.longTap(.mention(name)) break loop case let .botCommand(command): foundTapAction = true item.controllerInteraction.longTap(.command(command)) break loop case let .hashtag(_, hashtag): foundTapAction = true item.controllerInteraction.longTap(.hashtag(hashtag)) break loop case .instantPage: break case .call: break case .openMessage: foundTapAction = false break } } if !foundTapAction, let tapMessage = tapMessage { var subFrame = self.backgroundNode.frame if case .group = item.content { for contentNode in self.contentNodes { if contentNode.item?.message.stableId == tapMessage.stableId { subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) break } } } item.controllerInteraction.openMessageContextMenu(tapMessage, selectAll, self, subFrame) } } default: break } } default: break } } private func traceSelectionNodes(parent: ASDisplayNode, point: CGPoint) -> ASDisplayNode? { if let parent = parent as? GridMessageSelectionNode, parent.bounds.contains(point) { return parent } else { if let parentSubnodes = parent.subnodes { for subnode in parentSubnodes { let subnodeFrame = subnode.frame if let result = traceSelectionNodes(parent: subnode, point: point.offsetBy(dx: -subnodeFrame.minX, dy: -subnodeFrame.minY)) { return result } } } return nil } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if !self.bounds.contains(point) { return nil } if let shareButtonNode = self.shareButtonNode, shareButtonNode.frame.contains(point) { return shareButtonNode.view } if let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { return self.view } if let selectionNode = self.selectionNode { if let result = self.traceSelectionNodes(parent: self, point: point.offsetBy(dx: -42.0, dy: 0.0)) { return result.view } var selectionNodeFrame = selectionNode.frame selectionNodeFrame.origin.x -= 42.0 selectionNodeFrame.size.width += 42.0 * 2.0 if selectionNodeFrame.contains(point) { return selectionNode.view } else { return nil } } if !self.backgroundNode.frame.contains(point) { if self.actionButtonsNode == nil || !self.actionButtonsNode!.frame.contains(point) { //return nil } } return super.hitTest(point, with: event) } override func transitionNode(id: MessageId, media: Media) -> (ASDisplayNode, () -> UIView?)? { for contentNode in self.contentNodes { if let result = contentNode.transitionNode(messageId: id, media: media) { return result } } return nil } override func peekPreviewContent(at point: CGPoint) -> (Message, ChatMessagePeekPreviewContent)? { for contentNode in self.contentNodes { let frame = contentNode.frame if let result = contentNode.peekPreviewContent(at: point.offsetBy(dx: -frame.minX, dy: -frame.minY)) { return result } } return nil } override func updateHiddenMedia() { var hasHiddenMosaicStatus = false if let item = self.item { for contentNode in self.contentNodes { if let contentItem = contentNode.item { if contentNode.updateHiddenMedia(item.controllerInteraction.hiddenMedia[contentItem.message.id]) { if let mosaicStatusNode = self.mosaicStatusNode, mosaicStatusNode.frame.intersects(contentNode.frame) { hasHiddenMosaicStatus = true } } } } } if let mosaicStatusNode = self.mosaicStatusNode { if mosaicStatusNode.alpha.isZero != hasHiddenMosaicStatus { if hasHiddenMosaicStatus { mosaicStatusNode.alpha = 0.0 } else { mosaicStatusNode.alpha = 1.0 mosaicStatusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } } } override func updateAutomaticMediaDownloadSettings() { if let item = self.item { for contentNode in self.contentNodes { contentNode.updateAutomaticMediaDownloadSettings(item.controllerInteraction.automaticMediaDownloadSettings) } } } override func updateSelectionState(animated: Bool) { guard let item = self.item else { return } var canHaveSelection = true switch item.content { case let .message(message, _, _, _): for media in message.media { if let action = media as? TelegramMediaAction { if case .phoneCall = action.action { } else { canHaveSelection = false break } } } default: break } if let selectionState = item.controllerInteraction.selectionState, canHaveSelection { var selected = false var incoming = true switch item.content { case let .message(message, _, _, _): selected = selectionState.selectedIds.contains(message.id) case let .group(messages: messages): var allSelected = !messages.isEmpty for (message, _, _, _) in messages { if !selectionState.selectedIds.contains(message.id) { allSelected = false break } } selected = allSelected } incoming = item.message.effectivelyIncoming(item.account.peerId) let offset: CGFloat = incoming ? 42.0 : 0.0 if let selectionNode = self.selectionNode { selectionNode.updateSelected(selected, animated: animated) selectionNode.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentBounds.size.width, height: self.contentBounds.size.height)) self.subnodeTransform = CATransform3DMakeTranslation(offset, 0.0, 0.0); } else { let selectionNode = ChatMessageSelectionNode(theme: item.presentationData.theme.theme, toggle: { [weak self] value in if let strongSelf = self, let item = strongSelf.item { switch item.content { case let .message(message, _, _, _): item.controllerInteraction.toggleMessagesSelection([message.id], value) case let .group(messages): item.controllerInteraction.toggleMessagesSelection(messages.map { $0.0.id }, value) } } }) selectionNode.frame = CGRect(origin: CGPoint(x: -offset, y: 0.0), size: CGSize(width: self.contentBounds.size.width, height: self.contentBounds.size.height)) self.addSubnode(selectionNode) self.selectionNode = selectionNode selectionNode.updateSelected(selected, animated: false) let previousSubnodeTransform = self.subnodeTransform self.subnodeTransform = CATransform3DMakeTranslation(offset, 0.0, 0.0); if animated { selectionNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) self.layer.animate(from: NSValue(caTransform3D: previousSubnodeTransform), to: NSValue(caTransform3D: self.subnodeTransform), keyPath: "sublayerTransform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4) if !incoming { let position = selectionNode.layer.position selectionNode.layer.animatePosition(from: CGPoint(x: position.x - 42.0, y: position.y), to: position, duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring) } } } } else { if let selectionNode = self.selectionNode { self.selectionNode = nil let previousSubnodeTransform = self.subnodeTransform self.subnodeTransform = CATransform3DIdentity if animated { self.layer.animate(from: NSValue(caTransform3D: previousSubnodeTransform), to: NSValue(caTransform3D: self.subnodeTransform), keyPath: "sublayerTransform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.4, completion: { [weak selectionNode]_ in selectionNode?.removeFromSupernode() }) selectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false) if CGFloat(0.0).isLessThanOrEqualTo(selectionNode.frame.origin.x) { let position = selectionNode.layer.position selectionNode.layer.animatePosition(from: position, to: CGPoint(x: position.x - 42.0, y: position.y), duration: 0.4, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } } else { selectionNode.removeFromSupernode() } } } } override func updateHighlightedState(animated: Bool) { super.updateHighlightedState(animated: animated) guard let item = self.item, let _ = self.backgroundType else { return } var highlighted = false for contentNode in self.contentNodes { let _ = contentNode.updateHighlightedState(animated: animated) } if let highlightedState = item.controllerInteraction.highlightedState { for message in item.content { if highlightedState.messageStableId == message.stableId { highlighted = true break } } } if self.highlightedState != highlighted { self.highlightedState = highlighted if let backgroundType = self.backgroundType { let graphics = PresentationResourcesChat.principalGraphics(item.presentationData.theme.theme, wallpaper: item.presentationData.theme.wallpaper) if highlighted { self.backgroundNode.setType(type: backgroundType, highlighted: true, graphics: graphics, transition: .immediate) } else { if let previousContents = self.backgroundNode.layer.contents, animated { self.backgroundNode.setType(type: backgroundType, highlighted: false, graphics: graphics, transition: .immediate) if let updatedContents = self.backgroundNode.layer.contents { self.backgroundNode.layer.animate(from: previousContents as AnyObject, to: updatedContents as AnyObject, keyPath: "contents", timingFunction: kCAMediaTimingFunctionEaseInEaseOut, duration: 0.42) } } else { self.backgroundNode.setType(type: backgroundType, highlighted: false, graphics: graphics, transition: .immediate) } } } } } @objc func shareButtonPressed() { if let item = self.item { if item.content.firstMessage.id.peerId == item.account.peerId { for attribute in item.content.firstMessage.attributes { if let attribute = attribute as? SourceReferenceMessageAttribute { item.controllerInteraction.navigateToMessage(item.content.firstMessage.id, attribute.messageId) break } } } else { item.controllerInteraction.openMessageShareMenu(item.message.id) } } } @objc func swipeToReplyGesture(_ recognizer: ChatSwipeToReplyRecognizer) { switch recognizer.state { case .began: self.currentSwipeToReplyTranslation = 0.0 if self.swipeToReplyFeedback == nil { self.swipeToReplyFeedback = HapticFeedback() self.swipeToReplyFeedback?.prepareImpact() } self.item?.controllerInteraction.cancelInteractiveKeyboardGestures() case .changed: var translation = recognizer.translation(in: self.view) translation.x = max(-80.0, min(0.0, translation.x)) var animateReplyNodeIn = false if (translation.x < -45.0) != (self.currentSwipeToReplyTranslation < -45.0) { if translation.x < -45.0, self.swipeToReplyNode == nil, let item = self.item { self.swipeToReplyFeedback?.impact() let swipeToReplyNode = ChatMessageSwipeToReplyNode(fillColor: bubbleVariableColor(variableColor: item.presentationData.theme.theme.chat.bubble.shareButtonFillColor, wallpaper: item.presentationData.theme.wallpaper), strokeColor: item.presentationData.theme.theme.chat.bubble.shareButtonStrokeColor, foregroundColor: item.presentationData.theme.theme.chat.bubble.shareButtonForegroundColor) self.swipeToReplyNode = swipeToReplyNode self.addSubnode(swipeToReplyNode) animateReplyNodeIn = true } } self.currentSwipeToReplyTranslation = translation.x var bounds = self.bounds bounds.origin.x = -translation.x self.bounds = bounds if let swipeToReplyNode = self.swipeToReplyNode { swipeToReplyNode.frame = CGRect(origin: CGPoint(x: bounds.size.width, y: floor((self.contentSize.height - 33.0) / 2.0)), size: CGSize(width: 33.0, height: 33.0)) if animateReplyNodeIn { swipeToReplyNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.12) swipeToReplyNode.layer.animateSpring(from: 0.1 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.4) } else { swipeToReplyNode.alpha = min(1.0, abs(translation.x / 45.0)) } } case .cancelled, .ended: self.swipeToReplyFeedback = nil let translation = recognizer.translation(in: self.view) if case .ended = recognizer.state, translation.x < -45.0 { if let item = self.item { item.controllerInteraction.setupReply(item.message.id) } } var bounds = self.bounds let previousBounds = bounds bounds.origin.x = 0.0 self.bounds = bounds self.layer.animateBounds(from: previousBounds, to: bounds, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) if let swipeToReplyNode = self.swipeToReplyNode { self.swipeToReplyNode = nil swipeToReplyNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak swipeToReplyNode] _ in swipeToReplyNode?.removeFromSupernode() }) swipeToReplyNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false) } default: break } } }