import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import TelegramPresentationData import TelegramUIPreferences private func contentNodeMessagesAndClassesForItem(_ item: ChatMessageItem) -> [(Message, AnyClass)] { var result: [(Message, AnyClass)] = [] var skipText = false var messageWithCaptionToAdd: Message? var isUnsupportedMedia = 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 { var isVideo = file.isVideo || (file.isAnimated && file.dimensions != nil) #if DEBUG if let fileName = file.fileName, fileName.hasSuffix(".mkv") { isVideo = true } #endif if isVideo { 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 if case .phoneNumberRequest = action.action { result.append((message, ChatMessagePhoneNumberRequestContentNode.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 } else if let _ = media as? TelegramMediaPoll { result.append((message, ChatMessagePollBubbleContentNode.self)) } else if let _ = media as? TelegramMediaUnsupported { isUnsupportedMedia = true } } if !message.text.isEmpty || isUnsupportedMedia { if !skipText { if case .group = item.content { messageWithCaptionToAdd = message skipText = true } else { result.append((message, ChatMessageTextBubbleContentNode.self)) } } else { if case .group = item.content { messageWithCaptionToAdd = nil } } } 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 isUnsupportedMedia { result.append((message, ChatMessageUnsupportedBubbleContentNode.self)) } } if let messageWithCaptionToAdd = messageWithCaptionToAdd { result.append((messageWithCaptionToAdd, 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 credibilityIconNode: ASImageNode? var forwardInfoNode: ChatMessageForwardInfoNode? private var replyInfoNode: ChatMessageReplyInfoNode? private var contentNodes: [ChatMessageBubbleContentNode] = [] private var mosaicStatusNode: ChatMessageDateAndStatusNode? private var actionButtonsNode: ChatMessageActionButtonsNode? private var shareButtonNode: HighlightableButtonNode? private let messageAccessibilityArea: AccessibilityAreaNode private var backgroundType: ChatMessageBackgroundType? private var highlightedState: Bool = false private var backgroundFrameTransition: (CGRect, CGRect)? private var currentSwipeToReplyTranslation: CGFloat = 0.0 private var appliedItem: ChatMessageItem? private var appliedForwardInfo: (Peer?, String?)? override var visibility: ListViewItemNodeVisibility { didSet { if self.visibility != oldValue { for contentNode in self.contentNodes { contentNode.visibility = self.visibility } } } } required init() { self.backgroundNode = ChatMessageBackground() self.messageAccessibilityArea = AccessibilityAreaNode() super.init(layerBacked: false) self.addSubnode(self.backgroundNode) self.addSubnode(self.messageAccessibilityArea) self.messageAccessibilityArea.activate = { [weak self] in guard let strongSelf = self, let accessibilityData = strongSelf.accessibilityData else { return false } if let singleUrl = accessibilityData.singleUrl { strongSelf.item?.controllerInteraction.openUrl(singleUrl, false, false) } return false } } 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.credibilityIconNode?.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, .wallpaper, .call, .openMessage, .timecode, .tooltip: 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 let currentForwardInfo = self.appliedForwardInfo let isSelected = self.selectionNode?.selected let weakSelf = Weak(self) return { item, params, mergedTop, mergedBottom, dateHeaderAtBottom in return ChatMessageBubbleItemNode.beginLayout(selfReference: weakSelf, item, params, mergedTop, mergedBottom, dateHeaderAtBottom, currentContentClassesPropertiesAndLayouts: currentContentClassesPropertiesAndLayouts, authorNameLayout: authorNameLayout, adminBadgeLayout: adminBadgeLayout, forwardInfoLayout: forwardInfoLayout, replyInfoLayout: replyInfoLayout, actionButtonsLayout: actionButtonsLayout, mosaicStatusLayout: mosaicStatusLayout, currentShareButtonNode: currentShareButtonNode, layoutConstants: layoutConstants, currentItem: currentItem, currentForwardInfo: currentForwardInfo, isSelected: isSelected ) } } private static func beginLayout(selfReference: Weak, _ item: ChatMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: ChatMessageMerge, _ mergedBottom: ChatMessageMerge, _ dateHeaderAtBottom: Bool, 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))))], authorNameLayout: (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode), adminBadgeLayout: (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode), forwardInfoLayout: (ChatPresentationData, PresentationStrings, ChatMessageForwardInfoType, Peer?, String?, CGSize) -> (CGSize, () -> ChatMessageForwardInfoNode), replyInfoLayout: (ChatPresentationData, PresentationStrings, AccountContext, ChatMessageReplyInfoType, Message, CGSize) -> (CGSize, () -> ChatMessageReplyInfoNode), actionButtonsLayout: (AccountContext, ChatPresentationThemeData, PresentationStrings, ReplyMarkupMessageAttribute, Message, CGFloat) -> (minWidth: CGFloat, layout: (CGFloat) -> (CGSize, (Bool) -> ChatMessageActionButtonsNode)), mosaicStatusLayout: (ChatPresentationData, Bool, Int?, String, ChatMessageDateAndStatusType, CGSize) -> (CGSize, (Bool) -> ChatMessageDateAndStatusNode), currentShareButtonNode: HighlightableButtonNode?, layoutConstants: ChatMessageItemLayoutConstants, currentItem: ChatMessageItem?, currentForwardInfo: (Peer?, String?)?, isSelected: Bool? ) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation, Bool) -> Void) { let accessibilityData = ChatMessageAccessibilityData(item: item, isSelected: isSelected) let baseWidth = params.width - params.leftInset - params.rightInset let content = item.content let firstMessage = content.firstMessage let incoming = item.content.effectivelyIncoming(item.context.account.peerId) var sourceReference: SourceReferenceMessageAttribute? for attribute in item.content.firstMessage.attributes { if let attribute = attribute as? SourceReferenceMessageAttribute { sourceReference = attribute break } } var isCrosspostFromChannel = false if let _ = sourceReference { if firstMessage.id.peerId != item.context.account.peerId { isCrosspostFromChannel = true } } 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.context.account.peerId { if let forwardInfo = item.content.firstMessage.forwardInfo { ignoreForward = true effectiveAuthor = forwardInfo.author if effectiveAuthor == nil, let authorSignature = forwardInfo.authorSignature { effectiveAuthor = TelegramUser(id: PeerId(namespace: Namespaces.Peer.Empty, id: Int32(clamping: authorSignature.persistentHashValue)), accessHash: nil, firstName: authorSignature, lastName: nil, username: nil, phone: nil, photo: [], botInfo: nil, restrictionInfo: nil, flags: UserInfoFlags()) } } displayAuthorInfo = !mergedTop.merged && incoming && effectiveAuthor != nil } else if isCrosspostFromChannel, let sourceReference = sourceReference, let source = firstMessage.peers[sourceReference.messageId.peerId] { if firstMessage.forwardInfo?.author?.id == source.id { ignoreForward = true } effectiveAuthor = source displayAuthorInfo = !mergedTop.merged && incoming && effectiveAuthor != nil } else { effectiveAuthor = firstMessage.author displayAuthorInfo = !mergedTop.merged && incoming && peerId.isGroupOrChannel && effectiveAuthor != nil } if peerId != item.context.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.context.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.context.account.peerId { if let _ = sourceReference { needShareButton = true } } else if item.message.effectivelyIncoming(item.context.account.peerId) { if let _ = sourceReference { needShareButton = true } 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 } } var deliveryFailedInset: CGFloat = 0.0 if item.content.firstMessage.flags.contains(.Failed) { deliveryFailedInset += 24.0 } tmpWidth -= deliveryFailedInset 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 var authorIsChannel: Bool = false switch content { case let .message(message, _, _, attributes): if let peer = message.peers[message.id.peerId] as? TelegramChannel { if case .broadcast = peer.info { authorIsAdmin = false } else { if isCrosspostFromChannel, let sourceReference = sourceReference, let _ = firstMessage.peers[sourceReference.messageId.peerId] as? TelegramChannel { authorIsChannel = true } authorIsAdmin = attributes.isAdmin } } else { if isCrosspostFromChannel, let _ = firstMessage.forwardInfo?.source as? TelegramChannel { authorIsChannel = true } authorIsAdmin = attributes.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(context: item.context, 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 currentCredibilityIconImage: UIImage? 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)] var isScam = effectiveAuthor.isScam if case let .peer(peerId) = item.chatLocation, let authorPeerId = item.message.author?.id, authorPeerId == peerId { isScam = false } currentCredibilityIconImage = isScam ? PresentationResourcesChatList.scamIcon(item.presentationData.theme.theme, type: incoming ? .regular : .outgoing) : nil } 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 || author.flags.contains(.isSupport) { sentViaBot = true } let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings) let statusType: ChatMessageDateAndStatusType if message.effectivelyIncoming(item.context.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, 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 }) var forwardSource: Peer? var forwardAuthorSignature: String? 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) } else if authorIsChannel { adminBadgeString = NSAttributedString(string: " \(item.presentationData.strings.Channel_Status)", 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) } var credibilityIconWidth: CGFloat = 0.0 if let credibilityIconImage = currentCredibilityIconImage { credibilityIconWidth += credibilityIconImage.size.width + 4.0 } 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 - credibilityIconWidth - 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 + credibilityIconWidth) headerSize.height += nameNodeSizeApply.0.height } if !ignoreForward, let forwardInfo = firstMessage.forwardInfo { if headerSize.height.isZero { headerSize.height += 5.0 } if let source = forwardInfo.source { forwardSource = source if let authorSignature = forwardInfo.authorSignature { forwardAuthorSignature = authorSignature } else if let forwardInfoAuthor = forwardInfo.author, forwardInfoAuthor.id != source.id { forwardAuthorSignature = forwardInfoAuthor.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } else { forwardAuthorSignature = nil } } else { if let currentForwardInfo = currentForwardInfo, forwardInfo.author == nil && currentForwardInfo.0 != nil { forwardSource = nil forwardAuthorSignature = currentForwardInfo.0?.displayTitle } else { forwardSource = forwardInfo.author forwardAuthorSignature = forwardInfo.authorSignature } } 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.context, .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.context, 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), wide: position.isWide)) 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) } 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.context.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.context.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, { animation, synchronousLoads in return ChatMessageBubbleItemNode.applyLayout(selfReference: selfReference, animation, synchronousLoads, params: params, layout: layout, item: item, forwardSource: forwardSource, forwardAuthorSignature: forwardAuthorSignature, accessibilityData: accessibilityData, actionButtonsSizeAndApply: actionButtonsSizeAndApply, updatedMergedTop: updatedMergedTop, updatedMergedBottom: updatedMergedBottom, hideBackground: hideBackground, incoming: incoming, graphics: graphics, backgroundFrame: backgroundFrame, deliveryFailedInset: deliveryFailedInset, nameNodeSizeApply: nameNodeSizeApply, contentOrigin: contentOrigin, nameNodeOriginY: nameNodeOriginY, layoutConstants: layoutConstants, currentCredibilityIconImage: currentCredibilityIconImage, adminNodeSizeApply: adminNodeSizeApply, contentUpperRightCorner: contentUpperRightCorner, forwardInfoSizeApply: forwardInfoSizeApply, forwardInfoOriginY: forwardInfoOriginY, replyInfoSizeApply: replyInfoSizeApply, replyInfoOriginY: replyInfoOriginY, removedContentNodeIndices: removedContentNodeIndices, addedContentNodes: addedContentNodes, contentNodeMessagesAndClasses: contentNodeMessagesAndClasses, contentNodeFramesPropertiesAndApply: contentNodeFramesPropertiesAndApply, mosaicStatusOrigin: mosaicStatusOrigin, mosaicStatusSizeAndApply: mosaicStatusSizeAndApply, updatedShareButtonNode: updatedShareButtonNode, updatedShareButtonBackground: updatedShareButtonBackground ) }) } private static func applyLayout(selfReference: Weak, _ animation: ListViewItemUpdateAnimation, _ synchronousLoads: Bool, params: ListViewItemLayoutParams, layout: ListViewItemNodeLayout, item: ChatMessageItem, forwardSource: Peer?, forwardAuthorSignature: String?, accessibilityData: ChatMessageAccessibilityData, actionButtonsSizeAndApply: (CGSize, (Bool) -> ChatMessageActionButtonsNode)?, updatedMergedTop: ChatMessageMerge, updatedMergedBottom: ChatMessageMerge, hideBackground: Bool, incoming: Bool, graphics: PrincipalThemeEssentialGraphics, backgroundFrame: CGRect, deliveryFailedInset: CGFloat, nameNodeSizeApply: (CGSize, () -> TextNode?), contentOrigin: CGPoint, nameNodeOriginY: CGFloat, layoutConstants: ChatMessageItemLayoutConstants, currentCredibilityIconImage: UIImage?, adminNodeSizeApply: (CGSize, () -> TextNode?), contentUpperRightCorner: CGPoint, forwardInfoSizeApply: (CGSize, () -> ChatMessageForwardInfoNode?), forwardInfoOriginY: CGFloat, replyInfoSizeApply: (CGSize, () -> ChatMessageReplyInfoNode?), replyInfoOriginY: CGFloat, removedContentNodeIndices: [Int]?, addedContentNodes: [(Message, ChatMessageBubbleContentNode)]?, contentNodeMessagesAndClasses: [(Message, AnyClass)], contentNodeFramesPropertiesAndApply: [(CGRect, ChatMessageBubbleContentProperties, (ListViewItemUpdateAnimation, Bool) -> Void)], mosaicStatusOrigin: CGPoint?, mosaicStatusSizeAndApply: (CGSize, (Bool) -> ChatMessageDateAndStatusNode)?, updatedShareButtonNode: HighlightableButtonNode?, updatedShareButtonBackground: UIImage? ) -> Void { guard let strongSelf = selfReference.value else { return } strongSelf.appliedItem = item strongSelf.appliedForwardInfo = (forwardSource, forwardAuthorSignature) strongSelf.updateAccessibilityData(accessibilityData) 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: { [weak strongSelf] in if let item = strongSelf?.item { item.controllerInteraction.requestRedeliveryOfFailedMessages(item.content.firstMessage.id) } }) strongSelf.deliveryFailedNode = deliveryFailedNode strongSelf.insertSubnode(deliveryFailedNode, belowSubnode: strongSelf.messageAccessibilityArea) } 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.insertSubnode(nameNode, belowSubnode: strongSelf.messageAccessibilityArea) } nameNode.frame = CGRect(origin: CGPoint(x: contentOrigin.x + layoutConstants.text.bubbleInsets.left, y: layoutConstants.bubble.contentInsets.top + nameNodeOriginY), size: nameNodeSizeApply.0) if let credibilityIconImage = currentCredibilityIconImage { let credibilityIconNode: ASImageNode if let node = strongSelf.credibilityIconNode { credibilityIconNode = node } else { credibilityIconNode = ASImageNode() strongSelf.credibilityIconNode = credibilityIconNode strongSelf.insertSubnode(credibilityIconNode, belowSubnode: strongSelf.messageAccessibilityArea) } credibilityIconNode.frame = CGRect(origin: CGPoint(x: nameNode.frame.maxX + 4.0, y: nameNode.frame.minY), size: credibilityIconImage.size) credibilityIconNode.image = credibilityIconImage } else { strongSelf.credibilityIconNode?.removeFromSupernode() strongSelf.credibilityIconNode = nil } 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.insertSubnode(adminBadgeNode, belowSubnode: strongSelf.messageAccessibilityArea) 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.insertSubnode(forwardInfoNode, belowSubnode: strongSelf.messageAccessibilityArea) 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.insertSubnode(replyInfoNode, belowSubnode: strongSelf.messageAccessibilityArea) 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() { if index >= 0 && index < updatedContentNodes.count { 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 (_, contentNode) in addedContentNodes { updatedContentNodes.append(contentNode) strongSelf.insertSubnode(contentNode, belowSubnode: strongSelf.messageAccessibilityArea) 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) if contentNodeIndex >= strongSelf.contentNodes.count { break } 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.insertSubnode(mosaicStatusNode, belowSubnode: strongSelf.messageAccessibilityArea) } 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.insertSubnode(updatedShareButtonNode, belowSubnode: strongSelf.messageAccessibilityArea) 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 strongSelf.messageAccessibilityArea.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 = { [weak strongSelf] button in if let strongSelf = strongSelf { strongSelf.performMessageButtonAction(button: button) } } actionButtonsNode.buttonLongTapped = { [weak strongSelf] button in if let strongSelf = strongSelf { strongSelf.presentMessageButtonContextMenu(button: button) } } strongSelf.insertSubnode(actionButtonsNode, belowSubnode: strongSelf.messageAccessibilityArea) } 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 } strongSelf.updateSearchTextHighlightState() } override func updateAccessibilityData(_ accessibilityData: ChatMessageAccessibilityData) { super.updateAccessibilityData(accessibilityData) self.messageAccessibilityArea.accessibilityLabel = accessibilityData.label self.messageAccessibilityArea.accessibilityValue = accessibilityData.value self.messageAccessibilityArea.accessibilityHint = accessibilityData.hint self.messageAccessibilityArea.accessibilityTraits = accessibilityData.traits if let customActions = accessibilityData.customActions { self.messageAccessibilityArea.accessibilityCustomActions = customActions.map({ action -> UIAccessibilityCustomAction in return ChatMessageAccessibilityCustomAction(name: action.name, target: self, selector: #selector(self.performLocalAccessibilityCustomAction(_:)), action: action.action) }) } else { self.messageAccessibilityArea.accessibilityCustomActions = nil } } @objc private func performLocalAccessibilityCustomAction(_ action: UIAccessibilityCustomAction) { if let action = action as? ChatMessageAccessibilityCustomAction { switch action.action { case .reply: if let item = self.item { item.controllerInteraction.setupReply(item.message.id) } case .options: if let item = self.item { var subFrame = self.backgroundNode.frame if case .group = item.content { for contentNode in self.contentNodes { if contentNode.item?.message.stableId == item.message.stableId { subFrame = contentNode.frame.insetBy(dx: 0.0, dy: -4.0) break } } } item.controllerInteraction.openMessageContextMenu(item.message, false, self, subFrame) } } } } private func addContentNode(node: ChatMessageBubbleContentNode) { if let transitionClippingNode = self.transitionClippingNode { transitionClippingNode.addSubnode(node) } else { self.insertSubnode(node, belowSubnode: self.messageAccessibilityArea) } } 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.insertSubnode(node, belowSubnode: self.messageAccessibilityArea) self.transitionClippingNode = node } } private func disableTransitionClippingNode() { if let transitionClippingNode = self.transitionClippingNode { if let forwardInfoNode = self.forwardInfoNode { self.insertSubnode(forwardInfoNode, belowSubnode: self.messageAccessibilityArea) } if let replyInfoNode = self.replyInfoNode { self.insertSubnode(replyInfoNode, belowSubnode: self.messageAccessibilityArea) } for contentNode in self.contentNodes { self.insertSubnode(contentNode, belowSubnode: self.messageAccessibilityArea) } 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 self.messageAccessibilityArea.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 { var mediaMessage: Message? var forceOpen = false if let item = self.item { for media in item.message.media { if let file = media as? TelegramMediaFile, file.duration != nil { mediaMessage = item.message } } if mediaMessage == nil { for attribute in item.message.attributes { if let attribute = attribute as? ReplyMessageAttribute { if let replyMessage = item.message.associatedMessages[attribute.messageId] { for media in replyMessage.media { if let file = media as? TelegramMediaFile, file.duration != nil { mediaMessage = replyMessage forceOpen = true break } if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, webEmbedType(content: content).supportsSeeking { mediaMessage = replyMessage forceOpen = true break } } } } } } if mediaMessage == nil { mediaMessage = item.message } } 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 { var openPeerId = item.effectiveAuthorId ?? author.id var navigate: ChatControllerInteractionNavigateToPeer if item.content.firstMessage.id.peerId == item.context.account.peerId { navigate = .chat(textInputState: nil, messageId: nil) } else { navigate = .info } for attribute in item.content.firstMessage.attributes { if let attribute = attribute as? SourceReferenceMessageAttribute { openPeerId = attribute.messageId.peerId navigate = .chat(textInputState: nil, messageId: attribute.messageId) } } if item.effectiveAuthorId?.namespace == Namespaces.Peer.Empty { item.controllerInteraction.displayMessageTooltip(item.content.firstMessage.id, item.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, self, avatarNode.frame) } else { if let channel = item.content.firstMessage.forwardInfo?.author as? TelegramChannel, channel.username == nil { if case .member = channel.participationStatus { } else { item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_PrivateChannelTooltip, self, avatarNode.frame) return } } item.controllerInteraction.openPeer(openPeerId, 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 { if let channel = forwardInfo.author as? TelegramChannel, channel.username == nil { if case .member = channel.participationStatus { } else { item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_PrivateChannelTooltip, forwardInfoNode, nil) return } } item.controllerInteraction.navigateToMessage(item.message.id, sourceMessageId) } else if let id = forwardInfo.source?.id ?? forwardInfo.author?.id { item.controllerInteraction.openPeer(id, .info, nil) } else if let _ = forwardInfo.authorSignature { item.controllerInteraction.displayMessageTooltip(item.message.id, item.presentationData.strings.Conversation_ForwardAuthorHiddenTooltip, forwardInfoNode, 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, item.associatedData) } break loop case .wallpaper: foundTapAction = true if let item = self.item { item.controllerInteraction.openWallpaper(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 case let .timecode(timecode, _): foundTapAction = true if let item = self.item, let mediaMessage = mediaMessage { item.controllerInteraction.seekToTimecode(mediaMessage, timecode, forceOpen) } break loop case let .tooltip(text, node, rect): foundTapAction = true if let item = self.item { let _ = item.controllerInteraction.displayMessageTooltip(item.message.id, text, node, rect) } break loop } } if !foundTapAction { self.item?.controllerInteraction.clickThroughMessage() } case .longTap, .doubleTap: if let item = self.item, self.backgroundNode.frame.contains(location) { let message = item.message var foundTapAction = false var tapMessage: Message? = item.content.firstMessage var selectAll = true loop: for contentNode in self.contentNodes { if !contentNode.frame.contains(location) { continue loop } else if contentNode is ChatMessageMediaBubbleContentNode { selectAll = false } 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), message) break loop case let .peerMention(peerId, mention): foundTapAction = true item.controllerInteraction.longTap(.peerMention(peerId, mention), message) break loop case let .textMention(name): foundTapAction = true item.controllerInteraction.longTap(.mention(name), message) break loop case let .botCommand(command): foundTapAction = true item.controllerInteraction.longTap(.command(command), message) break loop case let .hashtag(_, hashtag): foundTapAction = true item.controllerInteraction.longTap(.hashtag(hashtag), message) break loop case .instantPage: break case .wallpaper: break case .call: break case .openMessage: foundTapAction = false break case let .timecode(timecode, text): foundTapAction = true if let mediaMessage = mediaMessage { item.controllerInteraction.longTap(.timecode(timecode, text), mediaMessage) } break loop case .tooltip: 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 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 let avatarNode = self.accessoryItemNode as? ChatMessageAvatarAccessoryItemNode, avatarNode.frame.contains(point) { return self.view } 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?, UIView?))? { for contentNode in self.contentNodes { if let result = contentNode.transitionNode(messageId: id, media: media) { if self.contentNodes.count == 1 && self.contentNodes.first is ChatMessageMediaBubbleContentNode && self.nameNode == nil && self.adminBadgeNode == nil && self.forwardInfoNode == nil && self.replyInfoNode == nil { return (result.0, { [weak self] in guard let strongSelf = self, let resultView = result.1().0 else { return (nil, nil) } if strongSelf.backgroundNode.supernode != nil, let backgroundView = strongSelf.backgroundNode.view.snapshotContentTree(unhide: true) { let backgroundFrame = strongSelf.backgroundNode.layer.convert(strongSelf.backgroundNode.bounds, to: result.0.layer) backgroundView.frame = backgroundFrame let viewWithBackground = UIView() viewWithBackground.addSubview(backgroundView) viewWithBackground.frame = resultView.frame resultView.frame = CGRect(origin: CGPoint(), size: resultView.frame.size) viewWithBackground.addSubview(resultView) return (viewWithBackground, backgroundView) } return (resultView, nil) }) } 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 var hasHiddenBackground = 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 self.contentNodes.count == 1 && self.contentNodes.first is ChatMessageMediaBubbleContentNode && self.nameNode == nil && self.adminBadgeNode == nil && self.forwardInfoNode == nil && self.replyInfoNode == nil { hasHiddenBackground = true } 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) } } } self.backgroundNode.isHidden = hasHiddenBackground } override func updateAutomaticMediaDownloadSettings() { if let item = self.item { for contentNode in self.contentNodes { contentNode.updateAutomaticMediaDownloadSettings(item.controllerInteraction.automaticMediaDownloadSettings) } } } override func playMediaWithSound() -> ((Double?) -> Void, Bool, Bool, Bool, ASDisplayNode?)? { for contentNode in self.contentNodes { if let playMediaWithSound = contentNode.playMediaWithSound() { return playMediaWithSound } } return nil } override func updateSelectionState(animated: Bool) { guard let item = self.item else { return } let wasSelected = self.selectionNode?.selected 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.context.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.insertSubnode(selectionNode, belowSubnode: self.messageAccessibilityArea) 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() } } } let isSelected = self.selectionNode?.selected if wasSelected != isSelected { self.updateAccessibilityData(ChatMessageAccessibilityData(item: item, isSelected: isSelected)) } } override func updateSearchTextHighlightState() { for contentNode in self.contentNodes { contentNode.updateSearchTextHighlightState(text: self.item?.controllerInteraction.searchTextHighightState) } } 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.context.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: bubbleVariableColor(variableColor: item.presentationData.theme.theme.chat.bubble.shareButtonStrokeColor, wallpaper: item.presentationData.theme.wallpaper), foregroundColor: bubbleVariableColor(variableColor: item.presentationData.theme.theme.chat.bubble.shareButtonForegroundColor, wallpaper: item.presentationData.theme.wallpaper)) self.swipeToReplyNode = swipeToReplyNode self.insertSubnode(swipeToReplyNode, belowSubnode: self.messageAccessibilityArea) 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 } } }