import Foundation import UIKit import AsyncDisplayKit import Display import CoreText import AppBundle import ComponentFlow import TextFormat import AccountContext import AnimationCache import MultiAnimationRenderer import TelegramCore import EmojiTextAttachmentView import InvisibleInkDustNode private final class InlineStickerItem: Hashable { let emoji: ChatTextInputTextCustomEmojiAttribute let file: TelegramMediaFile? let fontSize: CGFloat init(emoji: ChatTextInputTextCustomEmojiAttribute, file: TelegramMediaFile?, fontSize: CGFloat) { self.emoji = emoji self.file = file self.fontSize = fontSize } func hash(into hasher: inout Hasher) { hasher.combine(emoji.fileId) hasher.combine(self.fontSize) } static func ==(lhs: InlineStickerItem, rhs: InlineStickerItem) -> Bool { if lhs.emoji.fileId != rhs.emoji.fileId { return false } if lhs.file?.fileId != rhs.file?.fileId { return false } if lhs.fontSize != rhs.fontSize { return false } return true } } private final class RunDelegateData { let ascent: CGFloat let descent: CGFloat let width: CGFloat init(ascent: CGFloat, descent: CGFloat, width: CGFloat) { self.ascent = ascent self.descent = descent self.width = width } } public final class InteractiveTextNodeWithEntities { public final class Arguments { public let context: AccountContext public let cache: AnimationCache public let renderer: MultiAnimationRenderer public let placeholderColor: UIColor public let attemptSynchronous: Bool public let textColor: UIColor public let spoilerEffectColor: UIColor public let animation: ListViewItemUpdateAnimation public init( context: AccountContext, cache: AnimationCache, renderer: MultiAnimationRenderer, placeholderColor: UIColor, attemptSynchronous: Bool, textColor: UIColor, spoilerEffectColor: UIColor, animation: ListViewItemUpdateAnimation ) { self.context = context self.cache = cache self.renderer = renderer self.placeholderColor = placeholderColor self.attemptSynchronous = attemptSynchronous self.textColor = textColor self.spoilerEffectColor = spoilerEffectColor self.animation = animation } public func withUpdatedPlaceholderColor(_ color: UIColor) -> Arguments { return Arguments( context: self.context, cache: self.cache, renderer: self.renderer, placeholderColor: color, attemptSynchronous: self.attemptSynchronous, textColor: self.textColor, spoilerEffectColor: self.spoilerEffectColor, animation: self.animation ) } } private final class InlineStickerItemLayerData { let itemLayer: InlineStickerItemLayer var rect: CGRect = CGRect() init(itemLayer: InlineStickerItemLayer) { self.itemLayer = itemLayer } } public let textNode: InteractiveTextNode private var inlineStickerItemLayers: [InlineStickerItemLayer.Key: InlineStickerItemLayerData] = [:] private var dustEffectNodes: [Int: InvisibleInkDustNode] = [:] private var enableLooping: Bool = true public var visibilityRect: CGRect? { didSet { if !self.inlineStickerItemLayers.isEmpty && oldValue != self.visibilityRect { for (_, itemLayerData) in self.inlineStickerItemLayers { let isItemVisible: Bool if let visibilityRect = self.visibilityRect { if itemLayerData.rect.intersects(visibilityRect) { isItemVisible = true } else { isItemVisible = false } } else { isItemVisible = false } itemLayerData.itemLayer.isVisibleForAnimations = self.enableLooping && isItemVisible } } } } public init() { self.textNode = InteractiveTextNode() } private init(textNode: InteractiveTextNode) { self.textNode = textNode } public static func asyncLayout(_ maybeNode: InteractiveTextNodeWithEntities?) -> (InteractiveTextNodeLayoutArguments) -> (InteractiveTextNodeLayout, (InteractiveTextNodeWithEntities.Arguments?) -> InteractiveTextNodeWithEntities) { let makeLayout = InteractiveTextNode.asyncLayout(maybeNode?.textNode) return { [weak maybeNode] arguments in var updatedString: NSAttributedString? if let sourceString = arguments.attributedString { let string = NSMutableAttributedString(attributedString: sourceString) var fullRange = NSRange(location: 0, length: string.length) var originalTextId = 0 while true { var found = false string.enumerateAttribute(ChatTextInputAttributes.customEmoji, in: fullRange, options: [], using: { value, range, stop in if let value = value as? ChatTextInputTextCustomEmojiAttribute, let font = string.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont { let updatedSubstring = NSMutableAttributedString(string: "&") let replacementRange = NSRange(location: 0, length: updatedSubstring.length) updatedSubstring.addAttributes(string.attributes(at: range.location, effectiveRange: nil), range: replacementRange) updatedSubstring.addAttribute(NSAttributedString.Key("Attribute__EmbeddedItem"), value: InlineStickerItem(emoji: value, file: value.file, fontSize: font.pointSize), range: replacementRange) updatedSubstring.addAttribute(originalTextAttributeKey, value: OriginalTextAttribute(id: originalTextId, string: string.attributedSubstring(from: range).string), range: replacementRange) originalTextId += 1 let itemSize = (font.pointSize * 24.0 / 17.0) let runDelegateData = RunDelegateData( ascent: font.ascender, descent: font.descender, width: itemSize ) var callbacks = CTRunDelegateCallbacks( version: kCTRunDelegateCurrentVersion, dealloc: { dataRef in Unmanaged.fromOpaque(dataRef).release() }, getAscent: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().ascent }, getDescent: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().descent }, getWidth: { dataRef in let data = Unmanaged.fromOpaque(dataRef) return data.takeUnretainedValue().width } ) if let runDelegate = CTRunDelegateCreate(&callbacks, Unmanaged.passRetained(runDelegateData).toOpaque()) { updatedSubstring.addAttribute(NSAttributedString.Key(kCTRunDelegateAttributeName as String), value: runDelegate, range: replacementRange) } string.replaceCharacters(in: range, with: updatedSubstring) let updatedRange = NSRange(location: range.location, length: updatedSubstring.length) found = true stop.pointee = ObjCBool(true) fullRange = NSRange(location: updatedRange.upperBound, length: fullRange.upperBound - range.upperBound) } }) if !found { break } } updatedString = string } let (layout, apply) = makeLayout(arguments.withAttributedString(updatedString)) return (layout, { applyArguments in let animation: ListViewItemUpdateAnimation = applyArguments?.animation ?? .None let result = apply(animation) if let maybeNode = maybeNode { if let applyArguments = applyArguments { maybeNode.updateInteractiveContents(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor, attemptSynchronousLoad: false, textColor: applyArguments.textColor, spoilerEffectColor: applyArguments.spoilerEffectColor, animation: animation) } return maybeNode } else { let resultNode = InteractiveTextNodeWithEntities(textNode: result) if let applyArguments = applyArguments { resultNode.updateInteractiveContents(context: applyArguments.context, cache: applyArguments.cache, renderer: applyArguments.renderer, textLayout: layout, placeholderColor: applyArguments.placeholderColor, attemptSynchronousLoad: false, textColor: applyArguments.textColor, spoilerEffectColor: applyArguments.spoilerEffectColor, animation: .None) } return resultNode } }) } } private func isItemVisible(itemRect: CGRect) -> Bool { if let visibilityRect = self.visibilityRect { return itemRect.intersects(visibilityRect) } else { return false } } private func updateInteractiveContents( context: AccountContext, cache: AnimationCache, renderer: MultiAnimationRenderer, textLayout: InteractiveTextNodeLayout?, placeholderColor: UIColor, attemptSynchronousLoad: Bool, textColor: UIColor, spoilerEffectColor: UIColor, animation: ListViewItemUpdateAnimation ) { self.enableLooping = context.sharedContext.energyUsageSettings.loopEmoji var nextIndexById: [Int64: Int] = [:] var validIds: [InlineStickerItemLayer.Key] = [] var validDustEffectIds: [Int] = [] if let textLayout { for i in 0 ..< textLayout.segments.count { let segment = textLayout.segments[i] guard let segmentLayer = self.textNode.segmentLayer(index: i), let segmentItem = segmentLayer.item else { continue } for item in segment.embeddedItems { if let stickerItem = item.value as? InlineStickerItem { let index: Int if let currentNext = nextIndexById[stickerItem.emoji.fileId] { index = currentNext } else { index = 0 } nextIndexById[stickerItem.emoji.fileId] = index + 1 let id = InlineStickerItemLayer.Key(id: stickerItem.emoji.fileId, index: index) validIds.append(id) let itemSize = floorToScreenPixels(stickerItem.fontSize * 24.0 / 17.0) var itemFrame = CGRect(origin: item.rect.center, size: CGSize()).insetBy(dx: -itemSize / 2.0, dy: -itemSize / 2.0) itemFrame.origin.x = floorToScreenPixels(itemFrame.origin.x) itemFrame.origin.y = floorToScreenPixels(itemFrame.origin.y) itemFrame.origin.x += segmentItem.contentOffset.x itemFrame.origin.y += segmentItem.contentOffset.y let itemLayerData: InlineStickerItemLayerData if let current = self.inlineStickerItemLayers[id] { itemLayerData = current itemLayerData.itemLayer.dynamicColor = item.textColor if itemLayerData.itemLayer.superlayer !== segmentLayer.renderNode.layer { segmentLayer.addSublayer(itemLayerData.itemLayer) } } else { let pointSize = floor(itemSize * 1.3) itemLayerData = InlineStickerItemLayerData(itemLayer: InlineStickerItemLayer(context: context, userLocation: .other, attemptSynchronousLoad: attemptSynchronousLoad, emoji: stickerItem.emoji, file: stickerItem.file, cache: cache, renderer: renderer, placeholderColor: placeholderColor, pointSize: CGSize(width: pointSize, height: pointSize), dynamicColor: item.textColor)) self.inlineStickerItemLayers[id] = itemLayerData segmentLayer.renderNode.layer.addSublayer(itemLayerData.itemLayer) itemLayerData.itemLayer.isVisibleForAnimations = self.enableLooping && self.isItemVisible(itemRect: itemFrame.offsetBy(dx: -segmentItem.contentOffset.x, dy: -segmentItem.contentOffset.x)) } itemLayerData.itemLayer.opacity = item.isHiddenBySpoiler ? 0.0 : 1.0 itemLayerData.itemLayer.frame = itemFrame itemLayerData.rect = itemFrame.offsetBy(dx: -segmentItem.contentOffset.x, dy: -segmentItem.contentOffset.y) } } if !segment.spoilers.isEmpty { validDustEffectIds.append(i) let dustEffectNode: InvisibleInkDustNode if let current = self.dustEffectNodes[i] { dustEffectNode = current if dustEffectNode.layer.superlayer !== segmentLayer.renderNode.layer { segmentLayer.renderNode.layer.addSublayer(dustEffectNode.layer) } } else { dustEffectNode = InvisibleInkDustNode(textNode: nil, enableAnimations: context.sharedContext.energyUsageSettings.fullTranslucency) self.dustEffectNodes[i] = dustEffectNode segmentLayer.renderNode.layer.addSublayer(dustEffectNode.layer) } let dustNodeFrame = CGRect(origin: CGPoint(), size: segmentItem.size).insetBy(dx: -3.0, dy: -3.0) dustEffectNode.frame = dustNodeFrame dustEffectNode.update( size: dustNodeFrame.size, color: spoilerEffectColor, textColor: textColor, rects: segment.spoilers.map { $0.1.offsetBy(dx: 3.0 + segmentItem.contentOffset.x, dy: segmentItem.contentOffset.y + 3.0).insetBy(dx: 1.0, dy: 1.0) }, wordRects: segment.spoilerWords.map { $0.1.offsetBy(dx: segmentItem.contentOffset.x + 3.0, dy: segmentItem.contentOffset.y + 3.0).insetBy(dx: 1.0, dy: 1.0) } ) } } } var removeKeys: [InlineStickerItemLayer.Key] = [] for (key, itemLayerData) in self.inlineStickerItemLayers { if !validIds.contains(key) { removeKeys.append(key) itemLayerData.itemLayer.removeFromSuperlayer() } } for key in removeKeys { self.inlineStickerItemLayers.removeValue(forKey: key) } var removeDustEffectIds: [Int] = [] for (id, dustEffectNode) in self.dustEffectNodes { if !validDustEffectIds.contains(id) { removeDustEffectIds.append(id) dustEffectNode.removeFromSupernode() } } for id in removeDustEffectIds { self.dustEffectNodes.removeValue(forKey: id) } } }