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 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 linkPreviewOptionsDisposable: Disposable? private var linkPreviewHighlightingNodes: [LinkHighlightingNode] = [] private var quoteHighlightingNodes: [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(url, false, false, nil, nil) } 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 string = strongSelf.textNode.textNode.cachedLayout?.attributedString { let nsString = string.string as NSString let subRange = nsString.range(of: initialQuote.text) if subRange.location != NSNotFound { strongSelf.beginTextSelection(range: subRange, displayMenu: false) } } 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.updateLinkPreviewTextHighlightState(text: options.url) } }) } } } strongSelf.updateLinkProgressState() } }) }) }) } } 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 .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 .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 .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 .peerMention(peerId: peerMention.peerId, mention: peerMention.mention, openProfile: false) } else if let peerName = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.PeerTextMention)] as? String { return .textMention(peerName) } else if let botCommand = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BotCommand)] as? String { return .botCommand(botCommand) } else if let hashtag = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Hashtag)] as? TelegramHashtag { return .hashtag(hashtag.peerName, hashtag.hashtag) } else if let timecode = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Timecode)] as? TelegramTimecode { return .timecode(timecode.time, timecode.text) } else if let bankCard = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.BankCard)] as? String { return .bankCard(bankCard) } else if let pre = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Pre)] as? String { return .copy(pre) } else if let code = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.Code)] as? String { return .copy(code) } else if let emoji = attributes[NSAttributedString.Key(rawValue: ChatTextInputAttributes.customEmoji.rawValue)] as? ChatTextInputTextCustomEmojiAttribute, let file = emoji.file { return .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 .largeEmoji(emoji, fitz, emojiFile) } else { return .none } } else { return .none } } } else { if let _ = self.statusNode.hitTest(self.view.convert(point, to: self.statusNode.view), with: nil) { return .ignore } return .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 updateQuoteTextHighlightState(text: String?, animated: Bool) { 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.quoteHighlightingNodes.count { textHighlightNode = self.quoteHighlightingNodes[i] } else { textHighlightNode = 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.quoteHighlightingNodes.append(textHighlightNode) self.insertSubnode(textHighlightNode, belowSubnode: self.textNode.textNode) } textHighlightNode.frame = self.textNode.textNode.frame textHighlightNode.updateRects(rects) } for i in (rectsSet.count ..< self.quoteHighlightingNodes.count).reversed() { let node = self.quoteHighlightingNodes[i] self.quoteHighlightingNodes.remove(at: i) if animated { node.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak node] _ in node?.removeFromSupernode() }) } else { node.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 let enableQuote = true 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 } 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(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) } }