import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SwiftSignalKit import TelegramPresentationData import ItemListUI import PresentationDataUtils import AccountContext import TelegramStringFormatting import AccountContext import RadialStatusNode import SemanticStatusNode import PhotoResources import MusicAlbumArtResources import UniversalMediaPlayer import ContextUI import FileMediaResourceStatus import ManagedAnimationNode import ShimmerEffect import ComponentFlow import EmojiStatusComponent private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:]) private let redColors: (UInt32, UInt32) = (0xed6b7b, 0xe63f45) private let greenColors: (UInt32, UInt32) = (0x99de6f, 0x5fb84f) private let blueColors: (UInt32, UInt32) = (0x72d5fd, 0x2a9ef1) private let yellowColors: (UInt32, UInt32) = (0xffa24b, 0xed705c) private let extensionColorsMap: [String: (UInt32, UInt32)] = [ "ppt": redColors, "pptx": redColors, "pdf": redColors, "key": redColors, "xls": greenColors, "xlsx": greenColors, "csv": greenColors, "zip": yellowColors, "rar": yellowColors, "gzip": yellowColors, "ai": yellowColors ] private func generateExtensionImage(colors: (UInt32, UInt32)) -> UIImage? { return generateImage(CGSize(width: 40.0, height: 40.0), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.saveGState() context.beginPath() let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 ") context.clip() let gradientColors = [UIColor(rgb: colors.0).cgColor, UIColor(rgb: colors.1).cgColor] as CFArray var locations: [CGFloat] = [0.0, 1.0] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) context.restoreGState() context.beginPath() let _ = try? drawSvgPath(context, path: "M6,0 L26.7573593,0 C27.5530088,-8.52837125e-16 28.3160705,0.316070521 28.8786797,0.878679656 L39.1213203,11.1213203 C39.6839295,11.6839295 40,12.4469912 40,13.2426407 L40,34 C40,37.3137085 37.3137085,40 34,40 L6,40 C2.6862915,40 4.05812251e-16,37.3137085 0,34 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 ") context.clip() context.setFillColor(UIColor(rgb: 0xffffff, alpha: 0.2).cgColor) context.translateBy(x: 40.0 - 14.0, y: 0.0) let _ = try? drawSvgPath(context, path: "M-1,0 L14,0 L14,15 L14,14 C14,12.8954305 13.1045695,12 12,12 L4,12 C2.8954305,12 2,11.1045695 2,10 L2,2 C2,0.8954305 1.1045695,-2.02906125e-16 0,0 L-1,0 L-1,0 Z ") }) } private func extensionImage(fileExtension: String?) -> UIImage? { let colors: (UInt32, UInt32) if let fileExtension = fileExtension { if let extensionColors = extensionColorsMap[fileExtension] { colors = extensionColors } else { colors = blueColors } } else { colors = blueColors } if let cachedImage = (extensionImageCache.with { dict in return dict[colors.0] }) { return cachedImage } else if let image = generateExtensionImage(colors: colors) { let _ = extensionImageCache.modify { dict in var dict = dict dict[colors.0] = image return dict } return image } else { return nil } } private let extensionFont = Font.with(size: 15.0, design: .round, weight: .bold) private let mediumExtensionFont = Font.with(size: 14.0, design: .round, weight: .bold) private let smallExtensionFont = Font.with(size: 12.0, design: .round, weight: .bold) private struct FetchControls { let fetch: () -> Void let cancel: () -> Void } private enum FileIconImage: Equatable { case imageRepresentation(Media, TelegramMediaImageRepresentation) case albumArt(TelegramMediaFile, SharedMediaPlaybackAlbumArt) case roundVideo(TelegramMediaFile) static func ==(lhs: FileIconImage, rhs: FileIconImage) -> Bool { switch lhs { case let .imageRepresentation(lhsMedia, lhsValue): if case let .imageRepresentation(rhsMedia, rhsValue) = rhs, lhsMedia.isEqual(to: rhsMedia), lhsValue == rhsValue { return true } else { return false } case let .albumArt(file, value): if case .albumArt(file, value) = rhs { return true } else { return false } case let .roundVideo(file): if case .roundVideo(file) = rhs { return true } else { return false } } } } final class CachedChatListSearchResult { let text: String let searchQuery: String let resultRanges: [Range] init(text: String, searchQuery: String, resultRanges: [Range]) { self.text = text self.searchQuery = searchQuery self.resultRanges = resultRanges } func matches(text: String, searchQuery: String) -> Bool { if self.text != text { return false } if self.searchQuery != searchQuery { return false } return true } } public final class ListMessageFileItemNode: ListMessageNode { public final class DescriptionNode: ASDisplayNode { let descriptionNode: TextNode var titleTopicArrowNode: ASImageNode? var topicTitleNode: TextNode? var titleTopicIconView: ComponentHostView? var titleTopicIconComponent: EmojiStatusComponent? var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { if let titleTopicIconView = self.titleTopicIconView, let titleTopicIconComponent = self.titleTopicIconComponent { let _ = titleTopicIconView.update( transition: .immediate, component: AnyComponent(titleTopicIconComponent.withVisibleForAnimations(self.visibilityStatus)), environment: {}, containerSize: titleTopicIconView.bounds.size ) } } } } override init() { self.descriptionNode = TextNode() self.descriptionNode.displaysAsynchronously = true super.init() self.addSubnode(self.descriptionNode) } func asyncLayout() -> (_ context: AccountContext, _ constrainedWidth: CGFloat, _ theme: PresentationTheme, _ authorTitle: NSAttributedString?, _ topic: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)?) -> (CGSize, () -> Void) { let makeDescriptionLayout = TextNode.asyncLayout(self.descriptionNode) let makeTopicTitleLayout = TextNode.asyncLayout(self.topicTitleNode) return { [weak self] context, constrainedWidth, theme, authorTitle, topic in var maxTitleWidth = constrainedWidth if let _ = topic { maxTitleWidth = floor(constrainedWidth * 0.7) } let descriptionLayout = makeDescriptionLayout(TextNodeLayoutArguments(attributedString: authorTitle, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: maxTitleWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0))) var remainingWidth = constrainedWidth - descriptionLayout.0.size.width var topicTitleArguments: TextNodeLayoutArguments? var arrowIconImage: UIImage? if let topic = topic { remainingWidth -= 22.0 + 2.0 if authorTitle != nil { arrowIconImage = PresentationResourcesItemList.topicArrowDescriptionIcon(theme) if let arrowIconImage = arrowIconImage { remainingWidth -= arrowIconImage.size.width + 6.0 * 2.0 } } topicTitleArguments = TextNodeLayoutArguments(attributedString: topic.title, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: remainingWidth, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets(top: 2.0, left: 1.0, bottom: 2.0, right: 1.0)) } let topicTitleLayout = topicTitleArguments.flatMap(makeTopicTitleLayout) var size = descriptionLayout.0.size if let topicTitleLayout = topicTitleLayout { size.height = max(size.height, topicTitleLayout.0.size.height) size.width += 10.0 + topicTitleLayout.0.size.width } return (size, { guard let self else { return } let _ = descriptionLayout.1() let authorFrame = CGRect(origin: CGPoint(), size: descriptionLayout.0.size) self.descriptionNode.frame = authorFrame var nextX = authorFrame.maxX - 1.0 if authorTitle == nil { nextX = 0.0 } if let arrowIconImage = arrowIconImage { let titleTopicArrowNode: ASImageNode if let current = self.titleTopicArrowNode { titleTopicArrowNode = current } else { titleTopicArrowNode = ASImageNode() self.titleTopicArrowNode = titleTopicArrowNode self.addSubnode(titleTopicArrowNode) } titleTopicArrowNode.image = arrowIconImage nextX += 6.0 titleTopicArrowNode.frame = CGRect(origin: CGPoint(x: nextX, y: 5.0), size: arrowIconImage.size) nextX += arrowIconImage.size.width + 6.0 } else { if let titleTopicArrowNode = self.titleTopicArrowNode { self.titleTopicArrowNode = nil titleTopicArrowNode.removeFromSupernode() } } if let topic, topic.showIcon { let titleTopicIconView: ComponentHostView if let current = self.titleTopicIconView { titleTopicIconView = current } else { titleTopicIconView = ComponentHostView() self.titleTopicIconView = titleTopicIconView self.view.addSubview(titleTopicIconView) } let titleTopicIconContent: EmojiStatusComponent.Content if let fileId = topic.iconId, fileId != 0 { titleTopicIconContent = .animation(content: .customEmoji(fileId: fileId), size: CGSize(width: 36.0, height: 36.0), placeholderColor: theme.list.mediaPlaceholderColor, themeColor: theme.list.itemAccentColor, loopMode: .count(2)) } else { titleTopicIconContent = .topic(title: String(topic.title.string.prefix(1)), color: topic.iconColor, size: CGSize(width: 22.0, height: 22.0)) } let titleTopicIconComponent = EmojiStatusComponent( context: context, animationCache: context.animationCache, animationRenderer: context.animationRenderer, content: titleTopicIconContent, isVisibleForAnimations: self.visibilityStatus, action: nil ) self.titleTopicIconComponent = titleTopicIconComponent let iconSize = titleTopicIconView.update( transition: .immediate, component: AnyComponent(titleTopicIconComponent), environment: {}, containerSize: CGSize(width: 22.0, height: 22.0) ) titleTopicIconView.frame = CGRect(origin: CGPoint(x: nextX, y: UIScreenPixel), size: iconSize) nextX += iconSize.width + 2.0 } else { if let titleTopicIconView = self.titleTopicIconView { self.titleTopicIconView = nil titleTopicIconView.removeFromSuperview() } } if let topicTitleLayout = topicTitleLayout { let topicTitleNode = topicTitleLayout.1() if topicTitleNode.supernode == nil { self.addSubnode(topicTitleNode) self.topicTitleNode = topicTitleNode } topicTitleNode.frame = CGRect(origin: CGPoint(x: nextX - 1.0, y: 0.0), size: topicTitleLayout.0.size) } else if let topicTitleNode = self.topicTitleNode { self.topicTitleNode = nil topicTitleNode.removeFromSupernode() } }) } } } private let contextSourceNode: ContextExtractedContentContainingNode private let containerNode: ContextControllerSourceNode private let extractedBackgroundImageNode: ASImageNode private var extractedRect: CGRect? private var nonExtractedRect: CGRect? private let offsetContainerNode: ASDisplayNode private var backgroundNode: ASDisplayNode? private let highlightedBackgroundNode: ASDisplayNode public let separatorNode: ASDisplayNode private let maskNode: ASImageNode private var selectionNode: ItemListSelectableControlNode? public let titleNode: DescriptionNode public let textNode: TextNode public let descriptionNode: DescriptionNode private let descriptionProgressNode: ImmediateTextNode public let dateNode: TextNode public let extensionIconNode: ASImageNode private let extensionIconText: TextNode public let iconImageNode: TransformImageNode private let iconStatusNode: SemanticStatusNode private let restrictionNode: ASDisplayNode private var currentIconImage: FileIconImage? public var currentMedia: Media? private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) private var fetchStatus: MediaResourceStatus? private var resourceStatus: FileMediaResourceMediaStatus? private let fetchDisposable = MetaDisposable() private let playbackStatusDisposable = MetaDisposable() private let playbackStatus = Promise() private var downloadStatusIconNode: DownloadIconNode? private var linearProgressNode: LinearProgressNode? private var placeholderNode: ShimmerEffectNode? private var absoluteLocation: (CGRect, CGSize)? private var context: AccountContext? private(set) var message: Message? private var appliedItem: ListMessageItem? private var layoutParams: ListViewItemLayoutParams? private var contentSizeValue: CGSize? private var currentLeftOffset: CGFloat = 0.0 private var currentIsRestricted = false private var cachedSearchResult: CachedChatListSearchResult? public override var visibility: ListViewItemNodeVisibility { didSet { let wasVisible = self.visibilityStatus let isVisible: Bool switch self.visibility { case let .visible(fraction, _): isVisible = fraction > 0.2 case .none: isVisible = false } if wasVisible != isVisible { self.visibilityStatus = isVisible } } } private var visibilityStatus: Bool = false { didSet { if self.visibilityStatus != oldValue { self.descriptionNode.visibilityStatus = self.visibilityStatus } } } public required init() { self.contextSourceNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() self.separatorNode = ASDisplayNode() self.separatorNode.displaysAsynchronously = false self.separatorNode.isLayerBacked = true self.maskNode = ASImageNode() self.maskNode.isUserInteractionEnabled = false self.extractedBackgroundImageNode = ASImageNode() self.extractedBackgroundImageNode.displaysAsynchronously = false self.extractedBackgroundImageNode.alpha = 0.0 self.offsetContainerNode = ASDisplayNode() self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true self.titleNode = DescriptionNode() self.titleNode.displaysAsynchronously = false self.titleNode.isUserInteractionEnabled = false self.textNode = TextNode() self.textNode.displaysAsynchronously = false self.textNode.isUserInteractionEnabled = false self.descriptionNode = DescriptionNode() self.descriptionNode.displaysAsynchronously = false self.descriptionNode.isUserInteractionEnabled = false self.descriptionProgressNode = ImmediateTextNode() self.descriptionProgressNode.displaysAsynchronously = false self.descriptionProgressNode.isUserInteractionEnabled = false self.descriptionProgressNode.maximumNumberOfLines = 1 self.dateNode = TextNode() self.dateNode.isUserInteractionEnabled = false self.extensionIconNode = ASImageNode() self.extensionIconNode.isLayerBacked = true self.extensionIconNode.displaysAsynchronously = false self.extensionIconNode.displayWithoutProcessing = true self.extensionIconText = TextNode() self.extensionIconText.displaysAsynchronously = false self.extensionIconText.isUserInteractionEnabled = false self.iconImageNode = TransformImageNode() self.iconImageNode.displaysAsynchronously = false self.iconImageNode.contentAnimations = .subsequentUpdates self.iconStatusNode = SemanticStatusNode(backgroundNodeColor: .clear, foregroundNodeColor: .white) self.iconStatusNode.isUserInteractionEnabled = false self.restrictionNode = ASDisplayNode() self.restrictionNode.isHidden = true super.init() self.containerNode.addSubnode(self.contextSourceNode) self.containerNode.targetNodeForActivationProgress = self.contextSourceNode.contentNode self.addSubnode(self.containerNode) self.contextSourceNode.contentNode.addSubnode(self.extractedBackgroundImageNode) self.contextSourceNode.contentNode.addSubnode(self.offsetContainerNode) self.offsetContainerNode.addSubnode(self.titleNode) self.offsetContainerNode.addSubnode(self.textNode) self.offsetContainerNode.addSubnode(self.descriptionNode) self.offsetContainerNode.addSubnode(self.descriptionProgressNode) self.offsetContainerNode.addSubnode(self.dateNode) self.offsetContainerNode.addSubnode(self.extensionIconNode) self.offsetContainerNode.addSubnode(self.extensionIconText) self.offsetContainerNode.addSubnode(self.iconStatusNode) self.addSubnode(self.restrictionNode) self.addSubnode(self.separatorNode) self.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let item = strongSelf.item, let message = item.message else { return } cancelParentGestures(view: strongSelf.view) item.interaction.openMessageContextMenu(message, false, strongSelf.contextSourceNode, strongSelf.contextSourceNode.bounds, gesture) } self.contextSourceNode.willUpdateIsExtractedToContextPreview = { [weak self] isExtracted, transition in guard let strongSelf = self, let item = strongSelf.item else { return } if isExtracted { strongSelf.extractedBackgroundImageNode.image = generateStretchableFilledCircleImage(diameter: 28.0, color: item.presentationData.theme.theme.list.plainBackgroundColor) } if let extractedRect = strongSelf.extractedRect, let nonExtractedRect = strongSelf.nonExtractedRect { let rect = isExtracted ? extractedRect : nonExtractedRect transition.updateFrame(node: strongSelf.extractedBackgroundImageNode, frame: rect) } transition.updateSublayerTransformOffset(layer: strongSelf.offsetContainerNode.layer, offset: CGPoint(x: isExtracted ? 12.0 : 0.0, y: 0.0)) transition.updateAlpha(node: strongSelf.extractedBackgroundImageNode, alpha: isExtracted ? 1.0 : 0.0, completion: { _ in if !isExtracted { self?.extractedBackgroundImageNode.image = nil } }) transition.updateAlpha(node: strongSelf.dateNode, alpha: isExtracted ? 0.0 : 1.0) } } deinit { self.statusDisposable.dispose() self.fetchDisposable.dispose() } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupItem(_ item: ListMessageItem) { self.item = item } override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let item = item as? ListMessageItem { let doLayout = self.asyncLayout() let merged = (top: false, bottom: false, dateAtBottom: item.getDateAtBottom(top: previousItem, bottom: nextItem)) let (layout, apply) = doLayout(item, params, merged.top, merged.bottom, merged.dateAtBottom) self.contentSize = layout.contentSize self.insets = layout.insets apply(.None) } } override public func animateInsertion(_ currentTimestamp: Double, duration: Double, options: ListViewItemAnimationOptions) { super.animateInsertion(currentTimestamp, duration: duration, options: options) self.transitionOffset = self.bounds.size.height * 1.6 self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } override public func updateAbsoluteRect(_ rect: CGRect, within containerSize: CGSize) { var rect = rect rect.origin.y += self.insets.top self.absoluteLocation = (rect, containerSize) if let shimmerNode = self.placeholderNode { shimmerNode.updateAbsoluteRect(rect, within: containerSize) } } override public func asyncLayout() -> (_ item: ListMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { let titleNodeMakeLayout = self.titleNode.asyncLayout() let textNodeMakeLayout = TextNode.asyncLayout(self.textNode) let descriptionNodeMakeLayout = self.descriptionNode.asyncLayout() let extensionIconTextMakeLayout = TextNode.asyncLayout(self.extensionIconText) let dateNodeMakeLayout = TextNode.asyncLayout(self.dateNode) let iconImageLayout = self.iconImageNode.asyncLayout() let currentMedia = self.currentMedia let currentMessage = self.message let currentIconImage = self.currentIconImage let currentSearchResult = self.cachedSearchResult let currentItem = self.appliedItem let selectionNodeLayout = ItemListSelectableControlNode.asyncLayout(self.selectionNode) return { [weak self] item, params, mergedTop, mergedBottom, dateHeaderAtBottom in var updatedTheme: PresentationTheme? if currentItem?.presentationData.theme.theme !== item.presentationData.theme.theme { updatedTheme = item.presentationData.theme.theme } let titleFont = Font.semibold(floor(item.presentationData.fontSize.baseDisplaySize * 16.0 / 17.0)) let audioTitleFont = Font.semibold(floor(item.presentationData.fontSize.baseDisplaySize * 16.0 / 17.0)) let descriptionFont = Font.regular(floor(item.presentationData.fontSize.baseDisplaySize * 14.0 / 17.0)) let dateFont = Font.regular(floor(item.presentationData.fontSize.itemListBaseFontSize * 14.0 / 17.0)) let leftInset: CGFloat = 65.0 + params.leftInset let rightInset: CGFloat = 8.0 + params.rightInset var leftOffset: CGFloat = 0.0 var selectionNodeWidthAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)? if case let .selectable(selected) = item.selection { let (selectionWidth, selectionApply) = selectionNodeLayout(item.presentationData.theme.theme.list.itemCheckColors.strokeColor, item.presentationData.theme.theme.list.itemCheckColors.fillColor, item.presentationData.theme.theme.list.itemCheckColors.foregroundColor, selected, .regular) selectionNodeWidthAndApply = (selectionWidth, selectionApply) leftOffset += selectionWidth } var extensionIconImage: UIImage? var titleText: NSAttributedString? var descriptionText: NSAttributedString? var extensionText: NSAttributedString? var iconImage: FileIconImage? var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedStatusSignal: Signal? var updatedPlaybackStatusSignal: Signal? var updatedFetchControls: FetchControls? var isAudio = false var isVoice = false var isInstantVideo = false var isRestricted = false let message = item.message var titleExtraData: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)? = nil var descriptionExtraData: (title: NSAttributedString, showIcon: Bool, iconId: Int64?, iconColor: Int32)? = nil var globalAuthorTitle: String? var selectedMedia: Media? if let message = message { for media in message.media { if let file = media as? TelegramMediaFile { selectedMedia = file isInstantVideo = file.isInstantVideo for attribute in file.attributes { if case let .Audio(voice, duration, title, performer, _) = attribute { isAudio = true isVoice = voice titleText = NSAttributedString(string: title ?? (file.fileName ?? "Unknown Track"), font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) var descriptionString: String if let performer = performer { if item.isGlobalSearchResult || item.isDownloadList { descriptionString = performer } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)" } } else if let size = file.size { descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) } else { descriptionString = "" } if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if authorString.count > 1 { globalAuthorTitle = authorString.last ?? "" } if descriptionString.isEmpty { descriptionString = authorString.first ?? "" } else { descriptionString = "\(descriptionString) • \(authorString.first ?? "")" } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) if !voice { if file.fileName?.lowercased().hasSuffix(".ogg") == true { iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: "", performer: "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: "", performer: "", isThumbnail: false))) } else { iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(file: .message(message: MessageReference(message), media: file), title: title ?? "", performer: performer ?? "", isThumbnail: false))) } } else { titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) descriptionText = NSAttributedString(string: message.author.flatMap(EnginePeer.init)?.displayTitle(strings: item.presentationData.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } } } if isInstantVideo || isVoice { var authorName: String if let author = message.forwardInfo?.author { if author.id == item.context.account.peerId { authorName = item.presentationData.strings.DialogList_You } else { authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } } else if let signature = message.forwardInfo?.authorSignature { authorName = signature } else if let author = message.author { if author.id == item.context.account.peerId { authorName = item.presentationData.strings.DialogList_You } else { authorName = EnginePeer(author).displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } } else { authorName = " " } if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if authorString.count > 1 { globalAuthorTitle = authorString.last ?? "" } authorName = authorString.first ?? "" } titleText = NSAttributedString(string: authorName, font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if let duration = file.duration { if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo { descriptionString = stringForDuration(Int32(duration)) } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)" } } else { if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = dateString } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) iconImage = .roundVideo(file) } else if !isAudio { var fileName: String = file.fileName ?? "File" if file.isVideo { fileName = item.presentationData.strings.Message_Video } titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) var fileExtension: String? if let range = fileName.range(of: ".", options: [.backwards]) { fileExtension = fileName[range.upperBound...].lowercased() } extensionIconImage = extensionImage(fileExtension: fileExtension) if let fileExtension = fileExtension { extensionText = NSAttributedString(string: fileExtension, font: fileExtension.count > 3 ? mediumExtensionFont : extensionFont, textColor: UIColor.white) } if let representation = smallestImageRepresentation(file.previewRepresentations) { iconImage = .imageRepresentation(file, representation) } let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if let size = file.size { if item.isGlobalSearchResult || item.isDownloadList || !item.displayFileInfo { descriptionString = dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)) } else { descriptionString = "\(dataSizeString(size, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) • \(dateString)" } } else { if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = "\(dateString)" } } if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if authorString.count > 1 { globalAuthorTitle = authorString.last ?? "" } if descriptionString.isEmpty { descriptionString = authorString.first ?? "" } else { descriptionString = "\(descriptionString) • \(authorString.first ?? "")" } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } break } else if let image = media as? TelegramMediaImage { selectedMedia = image let fileName: String = item.presentationData.strings.Message_Photo titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) if let representation = smallestImageRepresentation(image.representations) { iconImage = .imageRepresentation(image, representation) } let dateString = stringForFullDate(timestamp: message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if !(item.isGlobalSearchResult || item.isDownloadList) { descriptionString = "\(dateString)" } if item.isGlobalSearchResult || item.isDownloadList { let authorString = stringForFullAuthorName(message: EngineMessage(message), strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if authorString.count > 1 { globalAuthorTitle = authorString.last ?? "" } if descriptionString.isEmpty { descriptionString = authorString.first ?? "" } else { descriptionString = "\(descriptionString) • \(authorString.first ?? "")" } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } } for attribute in message.attributes { if let attribute = attribute as? RestrictedContentMessageAttribute, attribute.platformText(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) != nil { isRestricted = true break } } } else { titleText = NSAttributedString(string: " ", font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) descriptionText = NSAttributedString(string: " ", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } if let _ = item.message?.threadId, item.message?.id.peerId.namespace == Namespaces.Peer.CloudChannel, let threadInfo = item.message?.associatedThreadInfo { if isInstantVideo || isVoice { titleExtraData = (NSAttributedString(string: threadInfo.title, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor), true, threadInfo.icon, threadInfo.iconColor) } else { descriptionExtraData = (NSAttributedString(string: threadInfo.title, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor), true, threadInfo.icon, threadInfo.iconColor) } } else if let globalAuthorTitle = globalAuthorTitle { if isInstantVideo || isVoice { titleExtraData = (NSAttributedString(string: globalAuthorTitle, font: titleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor), false, nil, 0) } else { descriptionExtraData = (NSAttributedString(string: globalAuthorTitle, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor), false, nil, 0) } } var mediaUpdated = false if let currentMedia = currentMedia { if let selectedMedia = selectedMedia { mediaUpdated = !selectedMedia.isEqual(to: currentMedia) } else { mediaUpdated = true } } else { mediaUpdated = selectedMedia != nil } var statusUpdated = mediaUpdated if currentMessage?.id != message?.id || currentMessage?.flags != message?.flags { statusUpdated = true } if let message = message, let selectedMedia = selectedMedia { if mediaUpdated { let context = item.context updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { if let file = selectedMedia as? TelegramMediaFile { strongSelf.fetchDisposable.set(messageMediaFileInteractiveFetched(context: context, message: message, file: file, userInitiated: true).start()) } else if let image = selectedMedia as? TelegramMediaImage, let representation = image.representations.last { strongSelf.fetchDisposable.set(messageMediaImageInteractiveFetched(context: context, message: message, image: image, resource: representation.resource, userInitiated: true, storeToDownloadsPeerId: nil).start()) } } }, cancel: { if let file = selectedMedia as? TelegramMediaFile { if item.isDownloadList { context.fetchManager.toggleInteractiveFetchPaused(resourceId: file.resource.id.stringRepresentation, isPaused: true) } else { messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: file) } } else if let image = selectedMedia as? TelegramMediaImage, let representation = image.representations.last { if item.isDownloadList { context.fetchManager.toggleInteractiveFetchPaused(resourceId: representation.resource.id.stringRepresentation, isPaused: true) } else { messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: image, resource: representation.resource) } } }) } if statusUpdated && item.displayFileInfo { if let file = selectedMedia as? TelegramMediaFile { updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: file, message: EngineMessage(message), isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult, isDownloadList: item.isDownloadList) |> mapToSignal { value -> Signal in if case .Fetching = value.fetchStatus, !item.isDownloadList { return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue()) } else { return .single(value) } } if isAudio || isInstantVideo { if let currentUpdatedStatusSignal = updatedStatusSignal { updatedStatusSignal = currentUpdatedStatusSignal |> map { status in switch status.mediaStatus { case .fetchStatus: if item.isDownloadList { return FileMediaResourceStatus(mediaStatus: .fetchStatus(status.fetchStatus), fetchStatus: status.fetchStatus) } else { return FileMediaResourceStatus(mediaStatus: .fetchStatus(.Local), fetchStatus: status.fetchStatus) } case .playbackStatus: return status } } } } if isVoice { updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: file, message: EngineMessage(message), isRecentActions: false, isGlobalSearch: item.isGlobalSearchResult, isDownloadList: item.isDownloadList) } } else if let image = selectedMedia as? TelegramMediaImage { updatedStatusSignal = messageImageMediaResourceStatus(context: item.context, image: image, message: EngineMessage(message), isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult || item.isDownloadList) |> mapToSignal { value -> Signal in if case .Fetching = value.fetchStatus, !item.isDownloadList { return .single(value) |> delay(0.1, queue: Queue.concurrentDefaultQueue()) } else { return .single(value) } } } } } var chatListSearchResult: CachedChatListSearchResult? let messageText = foldLineBreaks(item.message?.text ?? "") if let searchQuery = item.interaction.searchTextHighightState { if let cached = currentSearchResult, cached.matches(text: messageText, searchQuery: searchQuery) { chatListSearchResult = cached } else { let (ranges, text) = findSubstringRanges(in: messageText, query: searchQuery) chatListSearchResult = CachedChatListSearchResult(text: text, searchQuery: searchQuery, resultRanges: ranges) } } else { chatListSearchResult = nil } var captionText: NSMutableAttributedString? if let chatListSearchResult = chatListSearchResult, let firstRange = chatListSearchResult.resultRanges.first { var text = NSMutableAttributedString(string: messageText, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) for range in chatListSearchResult.resultRanges { let stringRange = NSRange(range, in: chatListSearchResult.text) if stringRange.location >= 0 && stringRange.location + stringRange.length <= text.length { text.addAttribute(.foregroundColor, value: item.presentationData.theme.theme.chatList.messageHighlightedTextColor, range: stringRange) } } let firstRangeOrigin = chatListSearchResult.text.distance(from: chatListSearchResult.text.startIndex, to: firstRange.lowerBound) if firstRangeOrigin > 24 { var leftOrigin: Int = 0 (text.string as NSString).enumerateSubstrings(in: NSMakeRange(0, firstRangeOrigin), options: [.byWords, .reverse]) { (str, range1, _, _) in let distanceFromEnd = firstRangeOrigin - range1.location if (distanceFromEnd > 12 || range1.location == 0) && leftOrigin == 0 { leftOrigin = range1.location } } text = text.attributedSubstring(from: NSMakeRange(leftOrigin, text.length - leftOrigin)).mutableCopy() as! NSMutableAttributedString text.insert(NSAttributedString(string: "\u{2026}", attributes: [NSAttributedString.Key.font: descriptionFont, NSAttributedString.Key.foregroundColor: item.presentationData.theme.theme.list.itemSecondaryTextColor]), at: 0) } captionText = text } let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message?.timestamp ?? 0, relativeTo: timestamp, dateTimeFormat: item.presentationData.dateTimeFormat) let dateAttributedString = NSAttributedString(string: dateText, font: dateFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) let (dateNodeLayout, dateNodeApply) = dateNodeMakeLayout(TextNodeLayoutArguments(attributedString: dateAttributedString, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 12.0, height: CGFloat.greatestFiniteMagnitude), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(item.context, params.width - leftInset - leftOffset - rightInset - dateNodeLayout.size.width - 4.0, item.presentationData.theme.theme, titleText, titleExtraData) let (textNodeLayout, textNodeApply) = textNodeMakeLayout(TextNodeLayoutArguments(attributedString: captionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 30.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(item.context, params.width - leftInset - rightInset - 30.0, item.presentationData.theme.theme, descriptionText, descriptionExtraData) var (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) if extensionTextLayout.truncated, let text = extensionText?.string { extensionText = NSAttributedString(string: text, font: smallExtensionFont, textColor: .white, paragraphAlignment: .center) (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) } var iconImageApply: (() -> Void)? if let iconImage = iconImage { switch iconImage { case let .imageRepresentation(_, representation): let iconSize = CGSize(width: 40.0, height: 40.0) let imageCorners = ImageCorners(radius: 6.0) let arguments = TransformImageArguments(corners: imageCorners, imageSize: representation.dimensions.cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) case .albumArt: let iconSize = CGSize(width: 40.0, height: 40.0) let imageCorners = ImageCorners(radius: iconSize.width / 2.0) let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) case let .roundVideo(file): let iconSize = CGSize(width: 40.0, height: 40.0) let imageCorners = ImageCorners(radius: iconSize.width / 2.0) let arguments = TransformImageArguments(corners: imageCorners, imageSize: (file.dimensions ?? PixelDimensions(width: 320, height: 320)).cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.presentationData.theme.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) } } if let message = message { if currentIconImage != iconImage { if let iconImage = iconImage { switch iconImage { case let .imageRepresentation(media, representation): if let file = media as? TelegramMediaFile { updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, userLocation: .peer(message.id.peerId), mediaReference: FileMediaReference.message(message: MessageReference(message), media: file).abstract, representation: representation) } else if let image = media as? TelegramMediaImage { updateIconImageSignal = mediaGridMessagePhoto(account: item.context.account, userLocation: .peer(message.id.peerId), photoReference: ImageMediaReference.message(message: MessageReference(message), media: image)) } else { updateIconImageSignal = .complete() } case let .albumArt(file, albumArt): updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, engine: item.context.engine, fileReference: .message(message: MessageReference(message), media: file), albumArt: albumArt, thumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3), emptyColor: item.presentationData.theme.theme.list.itemAccentColor) case let .roundVideo(file): updateIconImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, userLocation: .peer(message.id.peerId), videoReference: FileMediaReference.message(message: MessageReference(message), media: file), autoFetchFullSizeThumbnail: true, overlayColor: UIColor(white: 0.0, alpha: 0.3)) } } else { updateIconImageSignal = .complete() } } } var insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) if dateHeaderAtBottom, let header = item.header { insets.top += header.height } if !mergedBottom, case .blocks = item.style { insets.bottom += 35.0 } let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 8.0 * 2.0 + titleNodeLayout.height - 5.0 + descriptionNodeLayout.height + (textNodeLayout.size.height > 0.0 ? textNodeLayout.size.height + 3.0 : 0.0)), insets: insets) return (nodeLayout, { animation in if let strongSelf = self { if strongSelf.downloadStatusIconNode == nil { strongSelf.downloadStatusIconNode = DownloadIconNode(theme: item.presentationData.theme.theme) } let transition: ContainedViewLayoutTransition if animation.isAnimated && currentItem?.message != nil { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate } strongSelf.restrictionNode.isHidden = !isRestricted strongSelf.containerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) strongSelf.contextSourceNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) strongSelf.offsetContainerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) strongSelf.contextSourceNode.contentNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) strongSelf.restrictionNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) let nonExtractedRect = CGRect(origin: CGPoint(), size: CGSize(width: nodeLayout.contentSize.width - 16.0, height: nodeLayout.contentSize.height)) let extractedRect = CGRect(origin: CGPoint(), size: nodeLayout.contentSize).insetBy(dx: 16.0 + params.leftInset, dy: 0.0) strongSelf.extractedRect = extractedRect strongSelf.nonExtractedRect = nonExtractedRect if strongSelf.contextSourceNode.isExtractedToContextPreview { strongSelf.extractedBackgroundImageNode.frame = extractedRect } else { strongSelf.extractedBackgroundImageNode.frame = nonExtractedRect } strongSelf.contextSourceNode.contentRect = extractedRect strongSelf.containerNode.isGestureEnabled = item.displayFileInfo strongSelf.currentIsRestricted = isRestricted || item.message == nil strongSelf.currentMedia = selectedMedia strongSelf.message = message strongSelf.context = item.context strongSelf.appliedItem = item strongSelf.layoutParams = params strongSelf.contentSizeValue = nodeLayout.contentSize strongSelf.currentLeftOffset = leftOffset if let _ = updatedTheme { if item.displayBackground { let backgroundNode: ASDisplayNode if let current = strongSelf.backgroundNode { backgroundNode = current } else { backgroundNode = ASDisplayNode() strongSelf.backgroundNode = backgroundNode strongSelf.insertSubnode(backgroundNode, at: 0) } backgroundNode.backgroundColor = item.presentationData.theme.theme.list.itemBlocksBackgroundColor } strongSelf.separatorNode.backgroundColor = item.presentationData.theme.theme.list.itemPlainSeparatorColor strongSelf.highlightedBackgroundNode.backgroundColor = item.presentationData.theme.theme.list.itemHighlightedBackgroundColor strongSelf.linearProgressNode?.updateTheme(theme: item.presentationData.theme.theme) strongSelf.restrictionNode.backgroundColor = item.presentationData.theme.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.6) strongSelf.downloadStatusIconNode?.updateTheme(theme: item.presentationData.theme.theme) } if let (selectionWidth, selectionApply) = selectionNodeWidthAndApply { let selectionFrame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: selectionWidth, height: nodeLayout.contentSize.height)) let selectionNode = selectionApply(selectionFrame.size, transition.isAnimated) if selectionNode !== strongSelf.selectionNode { strongSelf.selectionNode?.removeFromSupernode() strongSelf.selectionNode = selectionNode strongSelf.contextSourceNode.contentNode.addSubnode(selectionNode) selectionNode.frame = selectionFrame transition.animatePosition(node: selectionNode, from: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY)) } else { transition.updateFrame(node: selectionNode, frame: selectionFrame) } } else if let selectionNode = strongSelf.selectionNode { strongSelf.selectionNode = nil let selectionFrame = selectionNode.frame transition.updatePosition(node: selectionNode, position: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY), completion: { [weak selectionNode] _ in selectionNode?.removeFromSupernode() }) } transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset + leftOffset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: params.width - leftInset - leftOffset, height: UIScreenPixel))) strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top - UIScreenPixel), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel - nodeLayout.insets.bottom)) if let backgroundNode = strongSelf.backgroundNode { backgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height - nodeLayout.insets.bottom)) } switch item.style { case .plain: if strongSelf.maskNode.supernode != nil { strongSelf.maskNode.removeFromSupernode() } case .blocks: if strongSelf.maskNode.supernode == nil { strongSelf.addSubnode(strongSelf.maskNode) } let hasCorners = itemListHasRoundedBlockLayout(params) var hasTopCorners = false var hasBottomCorners = false if !mergedTop { hasTopCorners = true } if !mergedBottom { hasBottomCorners = true strongSelf.separatorNode.isHidden = hasCorners } else { strongSelf.separatorNode.isHidden = false } strongSelf.maskNode.image = hasCorners ? PresentationResourcesItemList.cornersImage(item.presentationData.theme.theme, top: hasTopCorners, bottom: hasBottomCorners) : nil if let backgroundNode = strongSelf.backgroundNode { strongSelf.maskNode.frame = backgroundNode.frame.insetBy(dx: params.leftInset, dy: 0.0) } } transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset - 1.0, y: 7.0), size: titleNodeLayout)) let _ = titleNodeApply() var descriptionOffset: CGFloat = 0.0 if let resourceStatus = strongSelf.resourceStatus { switch resourceStatus { case .playbackStatus: break case let .fetchStatus(fetchStatus): switch fetchStatus { case .Remote, .Fetching, .Paused: descriptionOffset = 14.0 case .Local: break } } } transition.updateFrame(node: strongSelf.textNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset, y: strongSelf.titleNode.frame.maxY + 1.0), size: textNodeLayout.size)) let _ = textNodeApply() transition.updateFrame(node: strongSelf.descriptionNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset - 1.0, y: strongSelf.titleNode.frame.maxY - 3.0 + (textNodeLayout.size.height > 0.0 ? textNodeLayout.size.height + 3.0 : 0.0)), size: descriptionNodeLayout)) let _ = descriptionNodeApply() let _ = dateNodeApply() transition.updateFrame(node: strongSelf.dateNode, frame: CGRect(origin: CGPoint(x: params.width - rightInset - dateNodeLayout.size.width, y: 11.0), size: dateNodeLayout.size)) strongSelf.dateNode.isHidden = !item.isGlobalSearchResult let iconSize = CGSize(width: 40.0, height: 40.0) let iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) transition.updateFrame(node: strongSelf.extensionIconNode, frame: iconFrame) strongSelf.extensionIconNode.image = extensionIconImage transition.updateFrame(node: strongSelf.extensionIconText, frame: CGRect(origin: CGPoint(x: iconFrame.minX + floorToScreenPixels((iconFrame.width - extensionTextLayout.size.width) / 2.0), y: iconFrame.minY + 7.0 + floorToScreenPixels((iconFrame.height - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size)) transition.updateFrame(node: strongSelf.iconStatusNode, frame: iconFrame) let _ = extensionTextApply() strongSelf.currentIconImage = iconImage if let updateIconImageSignal, let iconImage, case .albumArt = iconImage { strongSelf.iconStatusNode.setBackgroundImage(updateIconImageSignal, size: CGSize(width: 40.0, height: 40.0)) } if let iconImageApply = iconImageApply { if let updateImageSignal = updateIconImageSignal { strongSelf.iconImageNode.setSignal(updateImageSignal) } transition.updateFrame(node: strongSelf.iconImageNode, frame: iconFrame) if strongSelf.iconImageNode.supernode == nil { strongSelf.offsetContainerNode.insertSubnode(strongSelf.iconImageNode, belowSubnode: strongSelf.iconStatusNode) } iconImageApply() if strongSelf.extensionIconNode.supernode != nil { strongSelf.extensionIconNode.removeFromSupernode() } if strongSelf.extensionIconText.supernode != nil { strongSelf.extensionIconText.removeFromSupernode() } } else if strongSelf.iconImageNode.supernode != nil { strongSelf.iconImageNode.removeFromSupernode() if strongSelf.extensionIconNode.supernode == nil { strongSelf.offsetContainerNode.insertSubnode(strongSelf.extensionIconNode, belowSubnode: strongSelf.iconStatusNode) } if strongSelf.extensionIconText.supernode == nil { strongSelf.offsetContainerNode.insertSubnode(strongSelf.extensionIconText, belowSubnode: strongSelf.iconStatusNode) } } if let updatedStatusSignal = updatedStatusSignal { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] fileStatus in if let strongSelf = strongSelf { strongSelf.fetchStatus = fileStatus.fetchStatus._asStatus() strongSelf.resourceStatus = fileStatus.mediaStatus strongSelf.updateStatus(transition: .immediate) } })) } if let downloadStatusIconNode = strongSelf.downloadStatusIconNode { transition.updateFrame(node: downloadStatusIconNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset - 3.0, y: strongSelf.descriptionNode.frame.minY + floorToScreenPixels((strongSelf.descriptionNode.frame.height - 18.0) / 2.0) + UIScreenPixel), size: CGSize(width: 18.0, height: 18.0))) } if let updatedFetchControls = updatedFetchControls { let _ = strongSelf.fetchControls.swap(updatedFetchControls) } if let updatedPlaybackStatusSignal = updatedPlaybackStatusSignal { strongSelf.playbackStatus.set(updatedPlaybackStatusSignal) /*strongSelf.playbackStatusDisposable.set((updatedPlaybackStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { strongSelf.playerStatus = status } } }))*/ } strongSelf.updateStatus(transition: transition) if item.message == nil { let shimmerNode: ShimmerEffectNode if let current = strongSelf.placeholderNode { shimmerNode = current } else { shimmerNode = ShimmerEffectNode() strongSelf.placeholderNode = shimmerNode if strongSelf.separatorNode.supernode != nil { strongSelf.insertSubnode(shimmerNode, belowSubnode: strongSelf.separatorNode) } else { strongSelf.addSubnode(shimmerNode) } } shimmerNode.frame = CGRect(origin: CGPoint(), size: nodeLayout.contentSize) if let (rect, size) = strongSelf.absoluteLocation { shimmerNode.updateAbsoluteRect(rect, within: size) } var shapes: [ShimmerEffectNode.Shape] = [] let titleLineWidth: CGFloat = 120.0 let descriptionLineWidth: CGFloat = 60.0 let lineDiameter: CGFloat = 8.0 let titleFrame = strongSelf.titleNode.frame shapes.append(.roundedRectLine(startPoint: CGPoint(x: titleFrame.minX, y: titleFrame.minY + floorToScreenPixels((titleFrame.height - lineDiameter) / 2.0)), width: titleLineWidth, diameter: lineDiameter)) let descriptionFrame = strongSelf.descriptionNode.frame shapes.append(.roundedRectLine(startPoint: CGPoint(x: descriptionFrame.minX, y: descriptionFrame.minY + floorToScreenPixels((descriptionFrame.height - lineDiameter) / 2.0)), width: descriptionLineWidth, diameter: lineDiameter)) if let media = selectedMedia as? TelegramMediaFile, media.isInstantVideo { shapes.append(.circle(iconFrame)) } else { shapes.append(.roundedRect(rect: iconFrame, cornerRadius: 6.0)) } shimmerNode.update(backgroundColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor, foregroundColor: item.presentationData.theme.theme.list.mediaPlaceholderColor, shimmeringColor: item.presentationData.theme.theme.list.itemBlocksBackgroundColor.withAlphaComponent(0.4), shapes: shapes, size: nodeLayout.contentSize) } else if let shimmerNode = strongSelf.placeholderNode { strongSelf.placeholderNode = nil shimmerNode.removeFromSupernode() } } }) } } private func updateStatus(transition: ContainedViewLayoutTransition) { guard let item = self.item, let media = self.currentMedia, let _ = self.fetchStatus, let status = self.resourceStatus, let layoutParams = self.layoutParams, let contentSize = self.contentSizeValue else { return } var isAudio = false var isVoice = false var isInstantVideo = false if let file = media as? TelegramMediaFile { isAudio = file.isMusic || file.isVoice isVoice = file.isVoice isInstantVideo = file.isInstantVideo } var iconStatusState: SemanticStatusNodeState = .none var iconStatusBackgroundColor: UIColor = .clear var iconStatusForegroundColor: UIColor = .white if isVoice { iconStatusBackgroundColor = item.presentationData.theme.theme.list.itemAccentColor iconStatusForegroundColor = item.presentationData.theme.theme.list.itemCheckColors.foregroundColor } else if isAudio { iconStatusBackgroundColor = item.presentationData.theme.theme.list.itemAccentColor iconStatusForegroundColor = item.presentationData.theme.theme.list.itemCheckColors.foregroundColor } if !isAudio && !isInstantVideo { self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate) } else { if item.isDownloadList { self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate) } switch status { case let .fetchStatus(fetchStatus): switch fetchStatus { case let .Fetching(_, progress): if item.isDownloadList { iconStatusState = .progress(value: CGFloat(progress), cancelEnabled: true, appearance: nil) } case .Local: if isAudio || isInstantVideo { iconStatusState = .play } case .Remote, .Paused: if isAudio || isInstantVideo { iconStatusState = .play } } case let .playbackStatus(playbackStatus): switch playbackStatus { case .playing: iconStatusState = .pause case .paused: iconStatusState = .play } } } self.iconStatusNode.backgroundNodeColor = iconStatusBackgroundColor self.iconStatusNode.foregroundNodeColor = iconStatusForegroundColor self.iconStatusNode.overlayForegroundNodeColor = .white self.iconStatusNode.transitionToState(iconStatusState) } override public func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) if highlighted, let item = self.item, case .none = item.selection { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { if let backgroundNode = self.backgroundNode { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: backgroundNode) } else { self.insertSubnode(self.highlightedBackgroundNode, at: 0) } } } else { if self.highlightedBackgroundNode.supernode != nil { if animated { self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in if let strongSelf = self { if completed { strongSelf.highlightedBackgroundNode.removeFromSupernode() } } }) self.highlightedBackgroundNode.alpha = 0.0 } else { self.highlightedBackgroundNode.removeFromSupernode() } } } } override public func transitionNode(id: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if let item = self.item, let message = item.message, message.id == id, self.iconImageNode.supernode != nil { let iconImageNode = self.iconImageNode return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in return (iconImageNode?.view.snapshotContentTree(unhide: true), nil) }) } return nil } override public func updateHiddenMedia() { if let interaction = self.interaction, let item = self.item, let message = item.message, interaction.getHiddenMedia()[message.id] != nil { self.iconImageNode.isHidden = true } else { self.iconImageNode.isHidden = false } } override public func updateSelectionState(animated: Bool) { } public func cancelPreviewGesture() { self.containerNode.cancelGesture() } private func updateProgressFrame(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { guard let item = self.appliedItem else { return } var descriptionOffset: CGFloat = 0.0 var downloadingString: String? if let resourceStatus = self.resourceStatus { var maybeFetchStatus: MediaResourceStatus = .Local switch resourceStatus { case .playbackStatus: break case let .fetchStatus(fetchStatus): maybeFetchStatus = fetchStatus._asStatus() } if item.isDownloadList, let fetchStatus = self.fetchStatus { maybeFetchStatus = fetchStatus } switch maybeFetchStatus { case .Fetching(_, let progress), .Paused(let progress): if let file = self.currentMedia as? TelegramMediaFile, let size = file.size { downloadingString = "\(dataSizeString(Int(Float(size) * progress), forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData))) / \(dataSizeString(size, forceDecimal: true, formatting: DataSizeStringFormatting(chatPresentationData: item.presentationData)))" } descriptionOffset = 14.0 case .Remote: descriptionOffset = 14.0 case .Local: break } switch maybeFetchStatus { case .Fetching(_, let progress), .Paused(let progress): let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 3.0, width: floorToScreenPixels((size.width - 65.0 - leftInset - rightInset)), height: 3.0) let linearProgressNode: LinearProgressNode if let current = self.linearProgressNode { linearProgressNode = current } else { linearProgressNode = LinearProgressNode() linearProgressNode.updateTheme(theme: item.presentationData.theme.theme) self.linearProgressNode = linearProgressNode self.addSubnode(linearProgressNode) } transition.updateFrame(node: linearProgressNode, frame: progressFrame) linearProgressNode.updateProgress(value: CGFloat(progress), completion: {}) var animated = true if let downloadStatusIconNode = self.downloadStatusIconNode { if downloadStatusIconNode.supernode == nil { animated = false self.offsetContainerNode.addSubnode(downloadStatusIconNode) } if case .Paused = maybeFetchStatus { downloadStatusIconNode.enqueueState(.download, animated: animated) } else { downloadStatusIconNode.enqueueState(.pause, animated: animated) } } case .Local: if let linearProgressNode = self.linearProgressNode { self.linearProgressNode = nil linearProgressNode.updateProgress(value: 1.0, completion: { [weak linearProgressNode] in linearProgressNode?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { _ in linearProgressNode?.removeFromSupernode() }) }) } if let downloadStatusIconNode = self.downloadStatusIconNode { if downloadStatusIconNode.supernode != nil { downloadStatusIconNode.removeFromSupernode() } } case .Remote: if let linearProgressNode = self.linearProgressNode { self.linearProgressNode = nil linearProgressNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak linearProgressNode] _ in linearProgressNode?.removeFromSupernode() }) } if let downloadStatusIconNode = self.downloadStatusIconNode { var animated = true if downloadStatusIconNode.supernode == nil { animated = false self.offsetContainerNode.addSubnode(downloadStatusIconNode) } downloadStatusIconNode.enqueueState(.download, animated: animated) } } } else { if let linearProgressNode = self.linearProgressNode { self.linearProgressNode = nil linearProgressNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linearProgressNode] _ in linearProgressNode?.removeFromSupernode() }) } if let downloadStatusIconNode = self.downloadStatusIconNode { if downloadStatusIconNode.supernode != nil { downloadStatusIconNode.removeFromSupernode() } } } var descriptionFrame = self.descriptionNode.frame let originX = self.titleNode.frame.minX + descriptionOffset if !descriptionFrame.origin.x.isEqual(to: originX) { descriptionFrame.origin.x = originX transition.updateFrame(node: self.descriptionNode, frame: descriptionFrame) } let alphaTransition: ContainedViewLayoutTransition if item.isDownloadList { alphaTransition = .immediate } else { alphaTransition = .animated(duration: 0.3, curve: .easeInOut) } if downloadingString != nil { alphaTransition.updateAlpha(node: self.descriptionProgressNode, alpha: 1.0) alphaTransition.updateAlpha(node: self.descriptionNode, alpha: 0.0) } else { alphaTransition.updateAlpha(node: self.descriptionProgressNode, alpha: 0.0) alphaTransition.updateAlpha(node: self.descriptionNode, alpha: 1.0) } let descriptionFont = Font.with(size: floorToScreenPixels(item.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0), design: .regular, weight: .regular, traits: [.monospacedNumbers]) self.descriptionProgressNode.attributedText = NSAttributedString(string: downloadingString ?? "", font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) let descriptionSize = self.descriptionProgressNode.updateLayout(CGSize(width: size.width - 14.0, height: size.height)) transition.updateFrame(node: self.descriptionProgressNode, frame: CGRect(origin: CGPoint(x: self.descriptionNode.frame.minX, y: self.descriptionNode.frame.minY + floorToScreenPixels((self.descriptionNode.bounds.height - descriptionSize.height) / 2.0)), size: descriptionSize)) } public func activateMedia() { self.progressPressed() } func progressPressed() { if let resourceStatus = self.resourceStatus { switch resourceStatus { case let .fetchStatus(fetchStatus): switch fetchStatus { case .Fetching: if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: if let item = self.item, let message = item.message, let interaction = self.interaction { let _ = interaction.openMessage(message, .default) } } case .playbackStatus: if let context = self.context { context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: nil) } } } } override public func headers() -> [ListViewItemHeader]? { return self.item?.header.flatMap { [$0] } } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let item = self.item, case .selectable = item.selection { if self.bounds.contains(point) { return self.view } } return super.hitTest(point, with: event) } @objc private func statusPressed() { guard let _ = self.item, let fetchStatus = self.fetchStatus else { return } switch fetchStatus { case .Fetching: if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } case .Remote, .Paused: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: break } } public override var canBeSelected: Bool { return !self.currentIsRestricted } } private final class LinearProgressNode: ASDisplayNode { private let trackingNode: HierarchyTrackingNode private let barNode: ASImageNode private let shimmerNode: ASImageNode private let shimmerClippingNode: ASDisplayNode private var currentProgress: CGFloat = 0.0 private var currentProgressAnimation: (from: CGFloat, to: CGFloat, startTime: Double, completion: () -> Void)? private var shimmerPhase: CGFloat = 0.0 private var inHierarchyValue: Bool = false private var shouldAnimate: Bool = false private let animator: ConstantDisplayLinkAnimator override init() { var updateInHierarchy: ((Bool) -> Void)? self.trackingNode = HierarchyTrackingNode { value in updateInHierarchy?(value) } var animationStep: (() -> Void)? self.animator = ConstantDisplayLinkAnimator { animationStep?() } self.barNode = ASImageNode() self.barNode.isLayerBacked = true self.shimmerNode = ASImageNode() self.shimmerNode.contentMode = .scaleToFill self.shimmerClippingNode = ASDisplayNode() self.shimmerClippingNode.clipsToBounds = true super.init() self.addSubnode(trackingNode) self.addSubnode(self.barNode) self.shimmerClippingNode.addSubnode(self.shimmerNode) self.addSubnode(self.shimmerClippingNode) updateInHierarchy = { [weak self] value in guard let strongSelf = self else { return } if strongSelf.inHierarchyValue != value { strongSelf.inHierarchyValue = value strongSelf.updateAnimations() } } animationStep = { [weak self] in self?.update() } } func updateTheme(theme: PresentationTheme) { self.barNode.image = generateStretchableFilledCircleImage(diameter: 3.0, color: theme.list.itemAccentColor) self.shimmerNode.image = generateImage(CGSize(width: 100.0, height: 3.0), opaque: false, rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) let foregroundColor = theme.list.plainBackgroundColor.withAlphaComponent(0.4) let transparentColor = foregroundColor.withAlphaComponent(0.0).cgColor let peakColor = foregroundColor.cgColor var locations: [CGFloat] = [0.0, 0.5, 1.0] let colors: [CGColor] = [transparentColor, peakColor, transparentColor] let colorSpace = CGColorSpaceCreateDeviceRGB() let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)! context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions()) }) } func updateProgress(value: CGFloat, completion: @escaping () -> Void = {}) { if self.currentProgress.isEqual(to: value) { self.currentProgressAnimation = nil completion() } else { self.currentProgressAnimation = (self.currentProgress, value, CACurrentMediaTime(), completion) } } private func updateAnimations() { let shouldAnimate = self.inHierarchyValue if shouldAnimate != self.shouldAnimate { self.shouldAnimate = shouldAnimate self.animator.isPaused = !shouldAnimate } } private func update() { if let (fromValue, toValue, startTime, completion) = self.currentProgressAnimation { let duration: Double = 0.15 let timestamp = CACurrentMediaTime() let t = CGFloat((timestamp - startTime) / duration) if t >= 1.0 { self.currentProgress = toValue self.currentProgressAnimation = nil completion() } else { let clippedT = max(0.0, t) self.currentProgress = (1.0 - clippedT) * fromValue + clippedT * toValue } var progressWidth: CGFloat = self.bounds.width * self.currentProgress if progressWidth < 6.0 { progressWidth = 0.0 } let progressFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: progressWidth, height: 3.0)) self.barNode.frame = progressFrame self.shimmerClippingNode.frame = progressFrame } self.shimmerPhase += 3.5 let shimmerWidth: CGFloat = 160.0 let shimmerOffset = self.shimmerPhase.remainder(dividingBy: self.bounds.width + shimmerWidth / 2.0) self.shimmerNode.frame = CGRect(origin: CGPoint(x: shimmerOffset - shimmerWidth / 2.0, y: 0.0), size: CGSize(width: shimmerWidth, height: 3.0)) } } private enum DownloadIconNodeState: Equatable { case download case pause } private func generateDownloadIcon(color: UIColor) -> UIImage? { let animation = ManagedAnimationNode(size: CGSize(width: 18.0, height: 18.0)) animation.customColor = color animation.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01)) return animation.image } private final class DownloadIconNode: ASImageNode { private var customColor: UIColor private let duration: Double = 0.3 private var iconState: DownloadIconNodeState = .download private var animationNode: ManagedAnimationNode? init(theme: PresentationTheme) { self.customColor = theme.list.itemAccentColor super.init() self.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(theme, generate: { return generateDownloadIcon(color: theme.list.itemAccentColor) }) self.contentMode = .center } func updateTheme(theme: PresentationTheme) { if self.image != nil { self.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(theme, generate: { return generateDownloadIcon(color: theme.list.itemAccentColor) }) } self.customColor = theme.list.itemAccentColor self.animationNode?.customColor = self.customColor } func enqueueState(_ state: DownloadIconNodeState, animated: Bool) { guard self.iconState != state else { return } if self.animationNode == nil { let animationNode = ManagedAnimationNode(size: CGSize(width: 18.0, height: 18.0)) self.animationNode = animationNode animationNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 18.0, height: 18.0)) animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01)) self.addSubnode(animationNode) self.image = nil } guard let animationNode = self.animationNode else { return } let previousState = self.iconState self.iconState = state switch previousState { case .pause: switch state { case .download: if animated { animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 100, endFrame: 120), duration: self.duration)) } else { animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 0), duration: 0.01)) } case .pause: break } case .download: switch state { case .pause: if animated { animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 0, endFrame: 20), duration: self.duration)) } else { animationNode.trackTo(item: ManagedAnimationItem(source: .local("anim_shareddownload"), frames: .range(startFrame: 60, endFrame: 60), duration: 0.01)) } case .download: break } } } }