import Foundation import UIKit import AsyncDisplayKit import Display import TelegramCore import Postbox import TextFormat import UrlEscaping import TelegramUniversalVideoContent import TextSelectionNode import InvisibleInkDustNode import Emoji import AnimatedStickerNode import TelegramAnimatedStickerNode import SwiftSignalKit import AccountContext import YuvConversion import AnimationCache import LottieAnimationCache import MultiAnimationRenderer import EmojiTextAttachmentView import TextNodeWithEntities import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ShimmeringLinkNode import ChatMessageItemCommon import TextLoadingEffect import ChatControllerInteraction private final class CachedChatMessageText { let text: String let inputEntities: [MessageTextEntity]? let entities: [MessageTextEntity]? init(text: String, inputEntities: [MessageTextEntity]?, entities: [MessageTextEntity]?) { self.text = text self.inputEntities = inputEntities self.entities = entities } func matches(text: String, inputEntities: [MessageTextEntity]?) -> Bool { if self.text != text { return false } if let current = self.inputEntities, let inputEntities = inputEntities { if current != inputEntities { return false } } else if (self.inputEntities != nil) != (inputEntities != nil) { return false } return true } } public class ChatMessageTextBubbleContentNode: ChatMessageBubbleContentNode { private let textNode: TextNodeWithEntities private var spoilerTextNode: TextNodeWithEntities? private var dustNode: InvisibleInkDustNode? private let textAccessibilityOverlayNode: TextAccessibilityOverlayNode private let statusNode: ChatMessageDateAndStatusNode private var linkHighlightingNode: LinkHighlightingNode? private var shimmeringNode: ShimmeringLinkNode? private var textSelectionNode: TextSelectionNode? private var textHighlightingNodes: [LinkHighlightingNode] = [] private var cachedChatMessageText: CachedChatMessageText? private var textSelectionState: Promise? private var linkPreviewHighlightText: String? private var linkPreviewOptionsDisposable: Disposable? private var linkPreviewHighlightingNodes: [LinkHighlightingNode] = [] private var quoteHighlightingNode: LinkHighlightingNode? private var linkProgressRange: NSRange? private var linkProgressView: TextLoadingEffectView? private var linkProgressDisposable: Disposable? override public var visibility: ListViewItemNodeVisibility { didSet { if oldValue != self.visibility { switch self.visibility { case .none: self.textNode.visibilityRect = nil self.spoilerTextNode?.visibilityRect = nil case let .visible(_, subRect): var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 self.textNode.visibilityRect = subRect self.spoilerTextNode?.visibilityRect = subRect } } } } required public init() { self.textNode = TextNodeWithEntities() self.statusNode = ChatMessageDateAndStatusNode() self.textAccessibilityOverlayNode = TextAccessibilityOverlayNode() super.init() self.textNode.textNode.isUserInteractionEnabled = false self.textNode.textNode.contentMode = .topLeft self.textNode.textNode.contentsScale = UIScreenScale self.textNode.textNode.displaysAsynchronously = true self.addSubnode(self.textNode.textNode) self.addSubnode(self.textAccessibilityOverlayNode) self.textAccessibilityOverlayNode.openUrl = { [weak self] url in self?.item?.controllerInteraction.openUrl(ChatControllerInteraction.OpenUrl(url: url, concealed: false, external: false)) } self.statusNode.reactionSelected = { [weak self] value in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.updateMessageReaction(item.message, .reaction(value)) } self.statusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in guard let strongSelf = self, let item = strongSelf.item else { gesture?.cancel() return } item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value) } } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { self.linkPreviewOptionsDisposable?.dispose() self.linkProgressDisposable?.dispose() } override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) { let textLayout = TextNodeWithEntities.asyncLayout(self.textNode) let spoilerTextLayout = TextNodeWithEntities.asyncLayout(self.spoilerTextNode) let statusLayout = self.statusNode.asyncLayout() let currentCachedChatMessageText = self.cachedChatMessageText return { item, layoutConstants, _, _, _, _ in let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none) return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in var topInset: CGFloat = 0.0 var bottomInset: CGFloat = 0.0 if case let .linear(top, bottom) = position { switch top { case .None: topInset = layoutConstants.text.bubbleInsets.top case let .Neighbour(_, topType, _): switch topType { case .text: topInset = layoutConstants.text.bubbleInsets.top - 2.0 case .header, .footer, .media, .reactions: topInset = layoutConstants.text.bubbleInsets.top } default: topInset = layoutConstants.text.bubbleInsets.top } switch bottom { case .None: bottomInset = layoutConstants.text.bubbleInsets.bottom default: bottomInset = layoutConstants.text.bubbleInsets.bottom - 3.0 } } let message = item.message let incoming: Bool if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } else { incoming = item.message.effectivelyIncoming(item.context.account.peerId) } var maxTextWidth = CGFloat.greatestFiniteMagnitude for media in item.message.media { if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, content.type == "telegram_background" || content.type == "telegram_theme" { maxTextWidth = layoutConstants.wallpapers.maxTextWidth break } } let horizontalInset = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right let textConstrainedSize = CGSize(width: min(maxTextWidth, constrainedSize.width - horizontalInset), height: constrainedSize.height) var edited = false if item.attributes.updatingMedia != nil { edited = true } var viewCount: Int? var dateReplies = 0 var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeer: item.associatedData.accountPeer, message: item.topMessage) if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) { dateReactionsAndPeers = ([], []) } for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { edited = !attribute.isHidden } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count } else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation { if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info { dateReplies = Int(attribute.count) } } } let dateFormat: MessageTimestampStatusFormat if let subject = item.associatedData.subject, case .messageOptions = subject { dateFormat = .minimal } else { dateFormat = .regular } let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? var displayStatus = false switch position { case let .linear(_, neighbor): if case .None = neighbor { displayStatus = true } else if case .Neighbour(true, _, _) = neighbor { displayStatus = true } default: break } if displayStatus { if incoming { statusType = .BubbleIncoming } else { if message.flags.contains(.Failed) { statusType = .BubbleOutgoing(.Failed) } else if (message.flags.isSending && !message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { statusType = .BubbleOutgoing(.Sending) } else { statusType = .BubbleOutgoing(.Sent(read: item.read)) } } } else { statusType = nil } var rawText: String var attributedText: NSAttributedString var messageEntities: [MessageTextEntity]? var mediaDuration: Double? = nil var isSeekableWebMedia = false var isUnsupportedMedia = false var story: Stories.Item? for media in item.message.media { if let file = media as? TelegramMediaFile, let duration = file.duration { mediaDuration = Double(duration) } if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content, webEmbedType(content: content).supportsSeeking { isSeekableWebMedia = true } else if media is TelegramMediaUnsupported { isUnsupportedMedia = true } else if let storyMedia = media as? TelegramMediaStory { if let value = item.message.associatedStories[storyMedia.storyId]?.get(Stories.StoredItem.self) { if case let .item(storyValue) = value { story = storyValue } } } } var isTranslating = false if let story { rawText = story.text messageEntities = story.entities } else if isUnsupportedMedia { rawText = item.presentationData.strings.Conversation_UnsupportedMediaPlaceholder messageEntities = [MessageTextEntity(range: 0.. $1.range.lowerBound }) { guard case let .CustomEmoji(_, fileId) = entity.type else { continue } let range = NSRange(location: entity.range.lowerBound, length: entity.range.upperBound - entity.range.lowerBound) let currentDict = updatedString.attributes(at: range.lowerBound, effectiveRange: nil) var updatedAttributes: [NSAttributedString.Key: Any] = currentDict //updatedAttributes[NSAttributedString.Key.foregroundColor] = UIColor.clear.cgColor updatedAttributes[ChatTextInputAttributes.customEmoji] = ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: item.message.associatedMedia[MediaId(namespace: Namespaces.Media.CloudFile, id: fileId)] as? TelegramMediaFile) let insertString = NSAttributedString(string: updatedString.attributedSubstring(from: range).string, attributes: updatedAttributes) updatedString.replaceCharacters(in: range, with: insertString) } attributedText = updatedString } let cutout: TextNodeCutout? = nil let textInsets = UIEdgeInsets(top: 2.0, left: 2.0, bottom: 5.0, right: 2.0) let (textLayout, textApply) = textLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor)) let spoilerTextLayoutAndApply: (TextNodeLayout, (TextNodeWithEntities.Arguments?) -> TextNodeWithEntities)? if !textLayout.spoilers.isEmpty { spoilerTextLayoutAndApply = spoilerTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: cutout, insets: textInsets, lineColor: messageTheme.accentControlColor, displaySpoilers: true, displayEmbeddedItemsUnderSpoilers: true)) } else { spoilerTextLayoutAndApply = nil } var statusSuggestedWidthAndContinue: (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation) -> Void))? if let statusType = statusType { var isReplyThread = false if case .replyThread = item.chatLocation { isReplyThread = true } let trailingWidthToMeasure: CGFloat if textLayout.hasRTL { trailingWidthToMeasure = 10000.0 } else { trailingWidthToMeasure = textLayout.trailingLineWidth } let dateLayoutInput: ChatMessageDateAndStatusNode.LayoutInput dateLayoutInput = .trailingContent(contentWidth: trailingWidthToMeasure, reactionSettings: ChatMessageDateAndStatusNode.TrailingReactionSettings(displayInline: shouldDisplayInlineDateReactions(message: item.message, isPremium: item.associatedData.isPremium, forceInline: item.associatedData.forceInlineReactions), preferAdditionalInset: false)) statusSuggestedWidthAndContinue = statusLayout(ChatMessageDateAndStatusNode.Arguments( context: item.context, presentationData: item.presentationData, edited: edited, impressionCount: viewCount, dateText: dateText, type: statusType, layoutInput: dateLayoutInput, constrainedSize: textConstrainedSize, availableReactions: item.associatedData.availableReactions, reactions: dateReactionsAndPeers.reactions, reactionPeers: dateReactionsAndPeers.peers, displayAllReactionPeers: item.message.id.peerId.namespace == Namespaces.Peer.CloudUser, replyCount: dateReplies, isPinned: item.message.tags.contains(.pinned) && (!item.associatedData.isInPinnedListMode || isReplyThread), hasAutoremove: item.message.isSelfExpiring, canViewReactionList: canViewMessageReactionList(message: item.message), animationCache: item.controllerInteraction.presentationContext.animationCache, animationRenderer: item.controllerInteraction.presentationContext.animationRenderer )) } var textFrame = CGRect(origin: CGPoint(x: -textInsets.left, y: -textInsets.top), size: textLayout.size) var textFrameWithoutInsets = CGRect(origin: CGPoint(x: textFrame.origin.x + textInsets.left, y: textFrame.origin.y + textInsets.top), size: CGSize(width: textFrame.width - textInsets.left - textInsets.right, height: textFrame.height - textInsets.top - textInsets.bottom)) textFrame = textFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset) textFrameWithoutInsets = textFrameWithoutInsets.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: topInset) var suggestedBoundingWidth: CGFloat = textFrameWithoutInsets.width if let statusSuggestedWidthAndContinue = statusSuggestedWidthAndContinue { suggestedBoundingWidth = max(suggestedBoundingWidth, statusSuggestedWidthAndContinue.0) } let sideInsets = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right suggestedBoundingWidth += sideInsets return (suggestedBoundingWidth, { boundingWidth in var boundingSize: CGSize let statusSizeAndApply = statusSuggestedWidthAndContinue?.1(boundingWidth - sideInsets) boundingSize = textFrameWithoutInsets.size if let statusSizeAndApply = statusSizeAndApply { boundingSize.height += statusSizeAndApply.0.height } boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right boundingSize.height += topInset + bottomInset return (boundingSize, { [weak self] animation, synchronousLoads, _ in if let strongSelf = self { strongSelf.item = item if let updatedCachedChatMessageText = updatedCachedChatMessageText { strongSelf.cachedChatMessageText = updatedCachedChatMessageText } let cachedLayout = strongSelf.textNode.textNode.cachedLayout if case .System = animation { if let cachedLayout = cachedLayout { if !cachedLayout.areLinesEqual(to: textLayout) { if let textContents = strongSelf.textNode.textNode.contents { let fadeNode = ASDisplayNode() fadeNode.displaysAsynchronously = false fadeNode.contents = textContents fadeNode.frame = strongSelf.textNode.textNode.frame fadeNode.isLayerBacked = true strongSelf.addSubnode(fadeNode) fadeNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak fadeNode] _ in fadeNode?.removeFromSupernode() }) strongSelf.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) } } } } let _ = textApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads)) animation.animator.updateFrame(layer: strongSelf.textNode.textNode.layer, frame: textFrame, completion: nil) if let (_, spoilerTextApply) = spoilerTextLayoutAndApply { let spoilerTextNode = spoilerTextApply(TextNodeWithEntities.Arguments(context: item.context, cache: item.controllerInteraction.presentationContext.animationCache, renderer: item.controllerInteraction.presentationContext.animationRenderer, placeholderColor: messageTheme.mediaPlaceholderColor, attemptSynchronous: synchronousLoads)) if strongSelf.spoilerTextNode == nil { spoilerTextNode.textNode.alpha = 0.0 spoilerTextNode.textNode.isUserInteractionEnabled = false spoilerTextNode.textNode.contentMode = .topLeft spoilerTextNode.textNode.contentsScale = UIScreenScale spoilerTextNode.textNode.displaysAsynchronously = false strongSelf.insertSubnode(spoilerTextNode.textNode, aboveSubnode: strongSelf.textAccessibilityOverlayNode) strongSelf.spoilerTextNode = spoilerTextNode } strongSelf.spoilerTextNode?.textNode.frame = textFrame let dustNode: InvisibleInkDustNode if let current = strongSelf.dustNode { dustNode = current } else { dustNode = InvisibleInkDustNode(textNode: spoilerTextNode.textNode, enableAnimations: item.context.sharedContext.energyUsageSettings.fullTranslucency) strongSelf.dustNode = dustNode strongSelf.insertSubnode(dustNode, aboveSubnode: spoilerTextNode.textNode) } dustNode.frame = textFrame.insetBy(dx: -3.0, dy: -3.0).offsetBy(dx: 0.0, dy: 3.0) dustNode.update(size: dustNode.frame.size, color: messageTheme.secondaryTextColor, textColor: messageTheme.primaryTextColor, rects: textLayout.spoilers.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: textLayout.spoilerWords.map { $0.1.offsetBy(dx: 3.0, dy: 3.0).insetBy(dx: 1.0, dy: 1.0) }) } else if let spoilerTextNode = strongSelf.spoilerTextNode { strongSelf.spoilerTextNode = nil spoilerTextNode.textNode.removeFromSupernode() if let dustNode = strongSelf.dustNode { strongSelf.dustNode = nil dustNode.removeFromSupernode() } } switch strongSelf.visibility { case .none: strongSelf.textNode.visibilityRect = nil strongSelf.spoilerTextNode?.visibilityRect = nil case let .visible(_, subRect): var subRect = subRect subRect.origin.x = 0.0 subRect.size.width = 10000.0 strongSelf.textNode.visibilityRect = subRect strongSelf.spoilerTextNode?.visibilityRect = subRect } if let textSelectionNode = strongSelf.textSelectionNode { let shouldUpdateLayout = textSelectionNode.frame.size != textFrame.size textSelectionNode.frame = textFrame textSelectionNode.highlightAreaNode.frame = textFrame if shouldUpdateLayout { textSelectionNode.updateLayout() } } strongSelf.textAccessibilityOverlayNode.frame = textFrame strongSelf.textAccessibilityOverlayNode.cachedLayout = textLayout strongSelf.updateIsTranslating(isTranslating) if let statusSizeAndApply = statusSizeAndApply { animation.animator.updateFrame(layer: strongSelf.statusNode.layer, frame: CGRect(origin: CGPoint(x: textFrameWithoutInsets.minX, y: textFrameWithoutInsets.maxY), size: statusSizeAndApply.0), completion: nil) if strongSelf.statusNode.supernode == nil { strongSelf.addSubnode(strongSelf.statusNode) statusSizeAndApply.1(.None) } else { statusSizeAndApply.1(animation) } } else if strongSelf.statusNode.supernode != nil { strongSelf.statusNode.removeFromSupernode() } if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported) { strongSelf.statusNode.pressed = { guard let strongSelf = self else { return } item.controllerInteraction.displayImportedMessageTooltip(strongSelf.statusNode) } } else { strongSelf.statusNode.pressed = nil } if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject { if case let .reply(info) = info { if strongSelf.textSelectionNode == nil { strongSelf.updateIsExtractedToContextPreview(true) if let initialQuote = info.quote, item.message.id == initialQuote.messageId { let nsString = item.message.text as NSString let subRange = nsString.range(of: initialQuote.text) if subRange.location != NSNotFound { strongSelf.beginTextSelection(range: subRange, displayMenu: true) } } if strongSelf.textSelectionState == nil { if let textSelectionNode = strongSelf.textSelectionNode { let range = textSelectionNode.getSelection() strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: range)) } else { strongSelf.textSelectionState = Promise(strongSelf.getSelectionState(range: nil)) } } if let textSelectionState = strongSelf.textSelectionState { info.selectionState.set(textSelectionState.get()) } } } else if case let .link(link) = info { if strongSelf.linkPreviewOptionsDisposable == nil { strongSelf.linkPreviewOptionsDisposable = (link.options |> deliverOnMainQueue).startStrict(next: { [weak strongSelf] options in guard let strongSelf else { return } if options.hasAlternativeLinks { strongSelf.linkPreviewHighlightText = options.url strongSelf.updateLinkPreviewTextHighlightState(text: strongSelf.linkPreviewHighlightText) } }) } } } strongSelf.updateLinkProgressState() if let linkPreviewHighlightText = strongSelf.linkPreviewHighlightText { strongSelf.updateLinkPreviewTextHighlightState(text: linkPreviewHighlightText) } } }) }) }) } } override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateAdded(_ currentTimestamp: Double, duration: Double) { self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.textNode.textNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) self.statusNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { if case .tap = gesture { } else { if let item = self.item, let subject = item.associatedData.subject, case .messageOptions = subject { return ChatMessageBubbleContentTapAction(content: .none) } } let textNodeFrame = self.textNode.textNode.frame if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)], !(self.dustNode?.isRevealed ?? true) { return ChatMessageBubbleContentTapAction(content: .none) } else if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String { var concealed = true var urlRange: NSRange? if let (attributeText, fullText, urlRangeValue) = self.textNode.textNode.attributeSubstringWithRange(name: TelegramTextAttributes.URL, index: index) { urlRange = urlRangeValue concealed = !doesUrlMatchText(url: url, text: attributeText, fullText: fullText) } return ChatMessageBubbleContentTapAction(content: .url(ChatMessageBubbleContentTapAction.Url(url: url, concealed: concealed)), activate: { [weak self] in guard let self else { return nil } let promise = Promise() self.linkProgressDisposable?.dispose() if self.linkProgressRange != nil { self.linkProgressRange = nil self.updateLinkProgressState() } self.linkProgressDisposable = (promise.get() |> deliverOnMainQueue).startStrict(next: { [weak self] value in guard let self else { return } let updatedRange: NSRange? = value ? urlRange : nil if self.linkProgressRange != updatedRange { self.linkProgressRange = updatedRange self.updateLinkProgressState() } }) return promise }) } else if let peerMention = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerMention)] as? TelegramPeerMention { return ChatMessageBubbleContentTapAction(content: .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false)) } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { return ChatMessageBubbleContentTapAction(content: .textMention(peerName)) } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { return ChatMessageBubbleContentTapAction(content: .botCommand(botCommand)) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { return ChatMessageBubbleContentTapAction(content: .hashtag(hashtag.peerName, hashtag.hashtag)) } else if let timecode = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Timecode)] as? TelegramTimecode { return ChatMessageBubbleContentTapAction(content: .timecode(timecode.time, timecode.text)) } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { return ChatMessageBubbleContentTapAction(content: .bankCard(bankCard)) } else if let pre = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Pre)] as? String { return ChatMessageBubbleContentTapAction(content: .copy(pre)) } else if let code = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Code)] as? String { return ChatMessageBubbleContentTapAction(content: .copy(code)) } else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file { return ChatMessageBubbleContentTapAction(content: .customEmoji(file)) } else { if let item = self.item, item.message.text.count == 1, !item.presentationData.largeEmoji { let (emoji, fitz) = item.message.text.basicEmoji var emojiFile: TelegramMediaFile? emojiFile = item.associatedData.animatedEmojiStickers[emoji]?.first?.file if emojiFile == nil { emojiFile = item.associatedData.animatedEmojiStickers[emoji.strippedEmoji]?.first?.file } if let emojiFile = emojiFile { return ChatMessageBubbleContentTapAction(content: .largeEmoji(emoji, fitz, emojiFile)) } else { return ChatMessageBubbleContentTapAction(content: .none) } } else { return ChatMessageBubbleContentTapAction(content: .none) } } } else { if let _ = self.statusNode.hitTest(self.view.convert(point, to: self.statusNode.view), with: nil) { return ChatMessageBubbleContentTapAction(content: .ignore) } return ChatMessageBubbleContentTapAction(content: .none) } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.statusNode.supernode != nil, let result = self.statusNode.hitTest(self.view.convert(point, to: self.statusNode.view), with: event) { return result } return super.hitTest(point, with: event) } private func updateIsTranslating(_ isTranslating: Bool) { guard let item = self.item else { return } let rects = self.textNode.textNode.rangeRects(in: NSRange(location: 0, length: self.textNode.textNode.cachedLayout?.attributedString?.length ?? 0))?.rects ?? [] if isTranslating, !rects.isEmpty { let shimmeringNode: ShimmeringLinkNode if let current = self.shimmeringNode { shimmeringNode = current } else { shimmeringNode = ShimmeringLinkNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.secondaryTextColor.withAlphaComponent(0.1) : item.presentationData.theme.theme.chat.message.outgoing.secondaryTextColor.withAlphaComponent(0.1)) shimmeringNode.updateRects(rects) shimmeringNode.frame = self.textNode.textNode.frame shimmeringNode.updateLayout(self.textNode.textNode.frame.size) shimmeringNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.shimmeringNode = shimmeringNode self.insertSubnode(shimmeringNode, belowSubnode: self.textNode.textNode) } } else if let shimmeringNode = self.shimmeringNode { self.shimmeringNode = nil shimmeringNode.alpha = 0.0 shimmeringNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak shimmeringNode] _ in shimmeringNode?.removeFromSupernode() }) } } override public func updateTouchesAtPoint(_ point: CGPoint?) { if let item = self.item { var rects: [CGRect]? var spoilerRects: [CGRect]? if let point = point { let textNodeFrame = self.textNode.textNode.frame if let (index, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: point.x - textNodeFrame.minX, y: point.y - textNodeFrame.minY)) { let possibleNames: [String] = [ TelegramTextAttributes.URL, TelegramTextAttributes.PeerMention, TelegramTextAttributes.PeerTextMention, TelegramTextAttributes.BotCommand, TelegramTextAttributes.Hashtag, TelegramTextAttributes.Timecode, TelegramTextAttributes.BankCard ] for name in possibleNames { if let _ = attributes[NSAttributedString.Key(rawValue: name)] { rects = self.textNode.textNode.attributeRects(name: name, at: index) break } } if let _ = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Spoiler)] { spoilerRects = self.textNode.textNode.attributeRects(name: TelegramTextAttributes.Spoiler, at: index) } } } if let spoilerRects = spoilerRects, !spoilerRects.isEmpty, let dustNode = self.dustNode, !dustNode.isRevealed { } else if let rects = rects { let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { linkHighlightingNode = current } else { linkHighlightingNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor) self.linkHighlightingNode = linkHighlightingNode self.insertSubnode(linkHighlightingNode, belowSubnode: self.textNode.textNode) } linkHighlightingNode.frame = self.textNode.textNode.frame linkHighlightingNode.updateRects(rects) } else if let linkHighlightingNode = self.linkHighlightingNode { self.linkHighlightingNode = nil linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in linkHighlightingNode?.removeFromSupernode() }) } } } override public func updateSearchTextHighlightState(text: String?, messages: [MessageIndex]?) { guard let item = self.item else { return } let rectsSet: [[CGRect]] if let text = text, let messages = messages, !text.isEmpty, messages.contains(item.message.index) { rectsSet = self.textNode.textNode.textRangesRects(text: text) } else { rectsSet = [] } for i in 0 ..< rectsSet.count { let rects = rectsSet[i] let textHighlightNode: LinkHighlightingNode if i < self.textHighlightingNodes.count { textHighlightNode = self.textHighlightingNodes[i] } else { textHighlightNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.textHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.textHighlightColor) self.textHighlightingNodes.append(textHighlightNode) self.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode) } textHighlightNode.frame = self.textNode.textNode.frame textHighlightNode.updateRects(rects) } for i in (rectsSet.count ..< self.textHighlightingNodes.count).reversed() { self.textHighlightingNodes[i].removeFromSupernode() self.textHighlightingNodes.remove(at: i) } } private func updateLinkPreviewTextHighlightState(text: String?) { guard let item = self.item else { return } var rectsSet: [[CGRect]] = [] if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string { let nsString = string as NSString let range = nsString.range(of: text) if range.location != NSNotFound { if let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty { rectsSet = [rects] } } } for i in 0 ..< rectsSet.count { let rects = rectsSet[i] let textHighlightNode: LinkHighlightingNode if i < self.linkPreviewHighlightingNodes.count { textHighlightNode = self.linkPreviewHighlightingNodes[i] } else { textHighlightNode = LinkHighlightingNode(color: item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor.withMultipliedAlpha(0.5) : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor.withMultipliedAlpha(0.5)) self.linkPreviewHighlightingNodes.append(textHighlightNode) self.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode) } textHighlightNode.frame = self.textNode.textNode.frame textHighlightNode.updateRects(rects) } for i in (rectsSet.count ..< self.linkPreviewHighlightingNodes.count).reversed() { self.linkPreviewHighlightingNodes[i].removeFromSupernode() self.linkPreviewHighlightingNodes.remove(at: i) } } private func updateLinkProgressState() { guard let item = self.item else { return } let range: NSRange = self.linkProgressRange ?? NSRange(location: NSNotFound, length: 0) if range.location != NSNotFound { let linkProgressView: TextLoadingEffectView if let current = self.linkProgressView { linkProgressView = current } else { linkProgressView = TextLoadingEffectView(frame: CGRect()) self.linkProgressView = linkProgressView self.view.addSubview(linkProgressView) } linkProgressView.frame = self.textNode.textNode.frame let progressColor: UIColor = item.message.effectivelyIncoming(item.context.account.peerId) ? item.presentationData.theme.theme.chat.message.incoming.linkHighlightColor : item.presentationData.theme.theme.chat.message.outgoing.linkHighlightColor linkProgressView.update(color: progressColor, textNode: self.textNode.textNode, range: range) } else { if let linkProgressView = self.linkProgressView { self.linkProgressView = nil linkProgressView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linkProgressView] _ in linkProgressView?.removeFromSuperview() }) } } } public func animateQuoteTextHighlightIn(sourceFrame: CGRect, transition: ContainedViewLayoutTransition) -> CGRect? { if let quoteHighlightingNode = self.quoteHighlightingNode { var currentRect = CGRect() for rect in quoteHighlightingNode.rects { if currentRect.isEmpty { currentRect = rect } else { currentRect = currentRect.union(rect) } } if !currentRect.isEmpty { currentRect = currentRect.insetBy(dx: -quoteHighlightingNode.inset, dy: -quoteHighlightingNode.inset) let innerRect = currentRect.offsetBy(dx: quoteHighlightingNode.frame.minX, dy: quoteHighlightingNode.frame.minY) quoteHighlightingNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, delay: 0.04) let fromScale = CGPoint(x: sourceFrame.width / innerRect.width, y: sourceFrame.height / innerRect.height) var fromTransform = CATransform3DIdentity let fromOffset = CGPoint(x: sourceFrame.midX - innerRect.midX, y: sourceFrame.midY - innerRect.midY) fromTransform = CATransform3DTranslate(fromTransform, fromOffset.x, fromOffset.y, 0.0) fromTransform = CATransform3DTranslate(fromTransform, -quoteHighlightingNode.bounds.width * 0.5 + currentRect.midX, -quoteHighlightingNode.bounds.height * 0.5 + currentRect.midY, 0.0) fromTransform = CATransform3DScale(fromTransform, fromScale.x, fromScale.y, 1.0) fromTransform = CATransform3DTranslate(fromTransform, quoteHighlightingNode.bounds.width * 0.5 - currentRect.midX, quoteHighlightingNode.bounds.height * 0.5 - currentRect.midY, 0.0) quoteHighlightingNode.transform = fromTransform transition.updateTransform(node: quoteHighlightingNode, transform: CGAffineTransformIdentity) return currentRect.offsetBy(dx: quoteHighlightingNode.frame.minX, dy: quoteHighlightingNode.frame.minY) } } return nil } public func getQuoteRect(quote: String) -> CGRect? { var rectsSet: [CGRect] = [] if !quote.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string { let nsString = string as NSString let range = nsString.range(of: quote) if range.location != NSNotFound { if let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty { rectsSet = rects } } } if !rectsSet.isEmpty { var currentRect = CGRect() for rect in rectsSet { if currentRect.isEmpty { currentRect = rect } else { currentRect = currentRect.union(rect) } } return currentRect.offsetBy(dx: self.textNode.textNode.frame.minX, dy: self.textNode.textNode.frame.minY) } return nil } public func updateQuoteTextHighlightState(text: String?, color: UIColor, animated: Bool) { var rectsSet: [CGRect] = [] if let text = text, !text.isEmpty, let cachedLayout = self.textNode.textNode.cachedLayout, let string = cachedLayout.attributedString?.string { let nsString = string as NSString let range = nsString.range(of: text) if range.location != NSNotFound { if let rects = cachedLayout.rangeRects(in: range)?.rects, !rects.isEmpty { rectsSet = rects } } } if !rectsSet.isEmpty { let rects = rectsSet let textHighlightNode: LinkHighlightingNode if let current = self.quoteHighlightingNode { textHighlightNode = current } else { textHighlightNode = LinkHighlightingNode(color: color) self.quoteHighlightingNode = textHighlightNode self.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode) } textHighlightNode.frame = self.textNode.textNode.frame textHighlightNode.updateRects(rects) } else { if let quoteHighlightingNode = self.quoteHighlightingNode { self.quoteHighlightingNode = nil if animated { quoteHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak quoteHighlightingNode] _ in quoteHighlightingNode?.removeFromSupernode() }) } else { quoteHighlightingNode.removeFromSupernode() } } } } override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { if !value { if let textSelectionNode = self.textSelectionNode { self.textSelectionNode = nil textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in textSelectionNode?.highlightAreaNode.removeFromSupernode() textSelectionNode?.removeFromSupernode() }) } } } override public func updateIsExtractedToContextPreview(_ value: Bool) { if value { if self.textSelectionNode == nil, let item = self.item, let rootNode = item.controllerInteraction.chatControllerNode() { let selectionColor: UIColor let knobColor: UIColor if item.message.effectivelyIncoming(item.context.account.peerId) { selectionColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionColor knobColor = item.presentationData.theme.theme.chat.message.incoming.textSelectionKnobColor } else { selectionColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionColor knobColor = item.presentationData.theme.theme.chat.message.outgoing.textSelectionKnobColor } let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: selectionColor, knob: knobColor), strings: item.presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { [weak self] value in self?.updateIsTextSelectionActive?(value) }, present: { [weak self] c, a in guard let self, let item = self.item else { return } if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { item.controllerInteraction.presentControllerInCurrent(c, a) } else { item.controllerInteraction.presentGlobalOverlayController(c, a) } }, rootNode: { [weak rootNode] in return rootNode }, performAction: { [weak self] text, action in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.performTextSelectionAction(item.message, true, text, action) }) textSelectionNode.updateRange = { [weak self] selectionRange in guard let strongSelf = self else { return } if let dustNode = strongSelf.dustNode, !dustNode.isRevealed, let textLayout = strongSelf.textNode.textNode.cachedLayout, !textLayout.spoilers.isEmpty, let selectionRange = selectionRange { for (spoilerRange, _) in textLayout.spoilers { if let intersection = selectionRange.intersection(spoilerRange), intersection.length > 0 { dustNode.update(revealed: true) return } } } if let textSelectionState = strongSelf.textSelectionState { textSelectionState.set(.single(strongSelf.getSelectionState(range: selectionRange))) } } let enableCopy = !item.associatedData.isCopyProtectionEnabled && !item.message.isCopyProtected() textSelectionNode.enableCopy = enableCopy var enableQuote = !item.message.text.isEmpty var enableOtherActions = true if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .reply = info { enableOtherActions = false } else if item.controllerInteraction.canSetupReply(item.message) == .reply { enableOtherActions = false } if !item.controllerInteraction.canSendMessages() && !enableCopy { enableQuote = false } if item.message.id.peerId.namespace == Namespaces.Peer.SecretChat { enableQuote = false } if item.message.containsSecretMedia { enableQuote = false } textSelectionNode.enableQuote = enableQuote textSelectionNode.enableTranslate = enableOtherActions textSelectionNode.enableShare = enableOtherActions textSelectionNode.menuSkipCoordnateConversion = !enableOtherActions self.textSelectionNode = textSelectionNode self.addSubnode(textSelectionNode) self.insertSubnode(textSelectionNode.highlightAreaNode, belowSubnode: self.textNode.textNode) textSelectionNode.frame = self.textNode.textNode.frame textSelectionNode.highlightAreaNode.frame = self.textNode.textNode.frame } } else { if let textSelectionNode = self.textSelectionNode { self.textSelectionNode = nil self.updateIsTextSelectionActive?(false) textSelectionNode.highlightAreaNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) textSelectionNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak textSelectionNode] _ in textSelectionNode?.highlightAreaNode.removeFromSupernode() textSelectionNode?.removeFromSupernode() }) } if let dustNode = self.dustNode, dustNode.isRevealed { dustNode.update(revealed: false) } } } override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { if !self.statusNode.isHidden { return self.statusNode.reactionView(value: value) } return nil } override public func getStatusNode() -> ASDisplayNode? { return self.statusNode } public func animateFrom(sourceView: UIView, scrollOffset: CGFloat, widthDifference: CGFloat, transition: CombinedTransition) { self.view.addSubview(sourceView) sourceView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, removeOnCompletion: false, completion: { [weak sourceView] _ in sourceView?.removeFromSuperview() }) self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.08) let offset = CGPoint( x: sourceView.frame.minX - (self.textNode.textNode.frame.minX - 0.0), y: sourceView.frame.minY - (self.textNode.textNode.frame.minY - 3.0) - scrollOffset ) transition.vertical.animatePositionAdditive(node: self.textNode.textNode, offset: offset) transition.updatePosition(layer: sourceView.layer, position: CGPoint(x: sourceView.layer.position.x - offset.x, y: sourceView.layer.position.y - offset.y)) self.statusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) transition.horizontal.animatePositionAdditive(node: self.statusNode, offset: CGPoint(x: -widthDifference, y: 0.0)) } public func beginTextSelection(range: NSRange?, displayMenu: Bool = true) { guard let textSelectionNode = self.textSelectionNode else { return } guard let string = self.textNode.textNode.cachedLayout?.attributedString else { return } let nsString = string.string as NSString let range = range ?? NSRange(location: 0, length: nsString.length) textSelectionNode.setSelection(range: range, displayMenu: displayMenu) } public func cancelTextSelection() { guard let textSelectionNode = self.textSelectionNode else { return } textSelectionNode.cancelSelection() } private func getSelectionState(range: NSRange?) -> ChatControllerSubject.MessageOptionsInfo.SelectionState { var quote: ChatControllerSubject.MessageOptionsInfo.Quote? if let item = self.item, let range, let selection = self.getCurrentTextSelection(customRange: range) { quote = ChatControllerSubject.MessageOptionsInfo.Quote(messageId: item.message.id, text: selection.text) } return ChatControllerSubject.MessageOptionsInfo.SelectionState(canQuote: true, quote: quote) } public func getCurrentTextSelection(customRange: NSRange? = nil) -> (text: String, entities: [MessageTextEntity])? { guard let textSelectionNode = self.textSelectionNode else { return nil } guard let range = customRange ?? textSelectionNode.getSelection() else { return nil } guard let item = self.item else { return nil } guard let string = self.textNode.textNode.cachedLayout?.attributedString else { return nil } let nsString = string.string as NSString let substring = nsString.substring(with: range) var entities: [MessageTextEntity] = [] if let textEntitiesAttribute = item.message.textEntitiesAttribute { entities = messageTextEntitiesInRange(entities: textEntitiesAttribute.entities, range: range, onlyQuoteable: true) } return (substring, entities) } }