import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SyncCore 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 private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:]) private let redColors: (UInt32, UInt32) = (0xf0625d, 0xde524e) private let greenColors: (UInt32, UInt32) = (0x72ce76, 0x54b658) private let blueColors: (UInt32, UInt32) = (0x60b0e8, 0x4597d1) private let yellowColors: (UInt32, UInt32) = (0xf5c565, 0xe5a64e) 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.setFillColor(UIColor(rgb: colors.0).cgColor) 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 Z ") 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: colors.0).withMultipliedBrightnessBy(0.85).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, traits: [.bold]) private struct FetchControls { let fetch: () -> Void let cancel: () -> Void } private enum FileIconImage: Equatable { case imageRepresentation(TelegramMediaFile, TelegramMediaImageRepresentation) case albumArt(TelegramMediaFile, SharedMediaPlaybackAlbumArt) case roundVideo(TelegramMediaFile) static func ==(lhs: FileIconImage, rhs: FileIconImage) -> Bool { switch lhs { case let .imageRepresentation(file, value): if case .imageRepresentation(file, value) = rhs { 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 } } } } public final class ListMessageFileItemNode: ListMessageNode { 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 let highlightedBackgroundNode: ASDisplayNode public let separatorNode: ASDisplayNode private var selectionNode: ItemListSelectableControlNode? public let titleNode: TextNode public let descriptionNode: TextNode private let descriptionProgressNode: ImmediateTextNode public let dateNode: TextNode private let extensionIconNode: ASImageNode private let extensionIconText: TextNode private let iconImageNode: TransformImageNode private let iconStatusNode: SemanticStatusNode 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: ASImageNode private var linearProgressNode: LinearProgressNode? 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 public required init() { self.contextSourceNode = ContextExtractedContentContainingNode() self.containerNode = ContextControllerSourceNode() self.separatorNode = ASDisplayNode() self.separatorNode.displaysAsynchronously = false self.separatorNode.isLayerBacked = true self.extractedBackgroundImageNode = ASImageNode() self.extractedBackgroundImageNode.displaysAsynchronously = false self.extractedBackgroundImageNode.alpha = 0.0 self.offsetContainerNode = ASDisplayNode() self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.descriptionNode = TextNode() self.descriptionNode.isUserInteractionEnabled = false self.descriptionProgressNode = ImmediateTextNode() 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.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.downloadStatusIconNode = ASImageNode() self.downloadStatusIconNode.isLayerBacked = true self.downloadStatusIconNode.displaysAsynchronously = false self.downloadStatusIconNode.displayWithoutProcessing = true super.init() self.addSubnode(self.separatorNode) 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.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.containerNode.activated = { [weak self] gesture, _ in guard let strongSelf = self, let item = strongSelf.item else { return } item.interaction.openMessageContextMenu(item.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, short: Bool) { super.animateInsertion(currentTimestamp, duration: duration, short: short) 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 asyncLayout() -> (_ item: ListMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { let titleNodeMakeLayout = TextNode.asyncLayout(self.titleNode) let descriptionNodeMakeLayout = TextNode.asyncLayout(self.descriptionNode) 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 currentItem = self.appliedItem let selectionNodeLayout = ItemListSelectableControlNode.asyncLayout(self.selectionNode) return { [weak self] item, params, _, _, 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)) var 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, false) 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 let message = item.message var selectedMedia: TelegramMediaFile? 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 { descriptionString = performer } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(performer)" } } else if let size = file.size { descriptionString = dataSizeString(size, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator) } else { descriptionString = "" } if item.isGlobalSearchResult { let authorString = stringForFullAuthorName(message: item.message, strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString } else { descriptionString = "\(descriptionString) • \(authorString)" } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) if !voice { iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: false))) } else { titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) descriptionText = NSAttributedString(string: item.message.author?.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 = 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 = author.displayTitle(strings: item.presentationData.strings, displayOrder: item.presentationData.nameDisplayOrder) } } else { authorName = " " } if item.isGlobalSearchResult { authorName = stringForFullAuthorName(message: item.message, strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) } titleText = NSAttributedString(string: authorName, font: audioTitleFont, textColor: item.presentationData.theme.theme.list.itemPrimaryTextColor) let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if let duration = file.duration { if item.isGlobalSearchResult { descriptionString = stringForDuration(Int32(duration)) } else { descriptionString = "\(stringForDuration(Int32(duration))) • \(dateString)" } } else { if !item.isGlobalSearchResult { descriptionString = dateString } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) iconImage = .roundVideo(file) } else if !isAudio { let fileName: String = file.fileName ?? "" 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: extensionFont, textColor: UIColor.white) } if let representation = smallestImageRepresentation(file.previewRepresentations) { iconImage = .imageRepresentation(file, representation) } let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.presentationData.strings, dateTimeFormat: item.presentationData.dateTimeFormat) var descriptionString: String = "" if let size = file.size { if item.isGlobalSearchResult { descriptionString = (dataSizeString(size, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator)) } else { descriptionString = "\(dataSizeString(size, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator)) • \(dateString)" } } else { if !item.isGlobalSearchResult { descriptionString = "\(dateString)" } } if item.isGlobalSearchResult { let authorString = stringForFullAuthorName(message: item.message, strings: item.presentationData.strings, nameDisplayOrder: item.presentationData.nameDisplayOrder, accountPeerId: item.context.account.peerId) if descriptionString.isEmpty { descriptionString = authorString } else { descriptionString = "\(descriptionString) • \(authorString)" } } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.presentationData.theme.theme.list.itemSecondaryTextColor) } break } } 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 selectedMedia = selectedMedia { if mediaUpdated { let context = item.context updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(messageMediaFileInteractiveFetched(context: context, message: message, file: selectedMedia, userInitiated: true).start()) } }, cancel: { messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: selectedMedia) }) } if statusUpdated { updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: selectedMedia, message: message, isRecentActions: false, isSharedMedia: true, isGlobalSearch: item.isGlobalSearchResult) if isAudio || isInstantVideo { if let currentUpdatedStatusSignal = updatedStatusSignal { updatedStatusSignal = currentUpdatedStatusSignal |> map { status in switch status.mediaStatus { case .fetchStatus: return FileMediaResourceStatus(mediaStatus: .fetchStatus(.Local), fetchStatus: status.fetchStatus) case .playbackStatus: return status } } } } if isVoice { updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: selectedMedia, message: message, isRecentActions: false, isGlobalSearch: item.isGlobalSearchResult) } } } let timestamp = Int32(CFAbsoluteTimeGetCurrent() + NSTimeIntervalSince1970) let dateText = stringForRelativeTimestamp(strings: item.presentationData.strings, relativeTimestamp: item.message.timestamp, 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(TextNodeLayoutArguments(attributedString: titleText, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .middle, constrainedSize: CGSize(width: params.width - leftInset - leftOffset - rightInset - dateNodeLayout.size.width - 4.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(TextNodeLayoutArguments(attributedString: descriptionText, 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 (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 currentIconImage != iconImage { if let iconImage = iconImage { switch iconImage { case let .imageRepresentation(file, representation): updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, fileReference: .message(message: MessageReference(message), media: file), representation: representation) case let .albumArt(file, albumArt): updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, 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, 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 } let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 8.0 * 2.0 + titleNodeLayout.size.height + 3.0 + descriptionNodeLayout.size.height), insets: insets) return (nodeLayout, { animation in if let strongSelf = self { let transition: ContainedViewLayoutTransition if animation.isAnimated { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate } 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) 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, 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.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 { 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) } 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: -UIScreenPixel - nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel)) transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: 9.0), size: titleNodeLayout.size)) 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: descriptionOffset = 14.0 case .Local: break } } } transition.updateFrame(node: strongSelf.descriptionNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset, y: strongSelf.titleNode.frame.maxY + 1.0), size: descriptionNodeLayout.size)) 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 iconFrame: CGRect if isAudio { let iconSize = CGSize(width: 40.0, height: 40.0) iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) } else { let iconSize = CGSize(width: 40.0, height: 40.0) 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 + floor((iconFrame.width - extensionTextLayout.size.width) / 2.0), y: iconFrame.minY + 2.0 + floor((iconFrame.height - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size)) transition.updateFrame(node: strongSelf.iconStatusNode, frame: iconFrame) let _ = extensionTextApply() strongSelf.currentIconImage = iconImage 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 strongSelf.resourceStatus = fileStatus.mediaStatus strongSelf.updateStatus(transition: .immediate) } })) } transition.updateFrame(node: strongSelf.downloadStatusIconNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: strongSelf.descriptionNode.frame.minY + floor((strongSelf.descriptionNode.frame.height - 12.0) / 2.0)), size: CGSize(width: 12.0, height: 12.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) } }) } } private func updateStatus(transition: ContainedViewLayoutTransition) { guard let item = self.item, let media = self.currentMedia, let fetchStatus = 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 } if !isAudio && !isInstantVideo { self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate) } else { switch status { case let .fetchStatus(fetchStatus): switch fetchStatus { case .Fetching: break case .Local: if isAudio || isInstantVideo { iconStatusState = .play } case .Remote: 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.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 { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) } } 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) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if let item = self.item, item.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, interaction.getHiddenMedia()[item.message.id] != nil { self.iconImageNode.isHidden = true } else { self.iconImageNode.isHidden = false } } override public func updateSelectionState(animated: Bool) { } 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 switch fetchStatus { case let .Fetching(_, progress): if let file = self.currentMedia as? TelegramMediaFile, let size = file.size { downloadingString = "\(dataSizeString(Int(Float(size) * progress), forceDecimal: true, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator)) / \(dataSizeString(size, forceDecimal: true, decimalSeparator: item.presentationData.dateTimeFormat.decimalSeparator))" } descriptionOffset = 14.0 case .Remote: descriptionOffset = 14.0 case .Local: break } } switch maybeFetchStatus { case let .Fetching(_, progress): let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 2.0, width: floor((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: {}) if self.downloadStatusIconNode.supernode == nil { self.offsetContainerNode.addSubnode(self.downloadStatusIconNode) } self.downloadStatusIconNode.image = PresentationResourcesChat.sharedMediaFileDownloadPauseIcon(item.presentationData.theme.theme) 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 self.downloadStatusIconNode.supernode != nil { self.downloadStatusIconNode.removeFromSupernode() } self.downloadStatusIconNode.image = nil 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 self.downloadStatusIconNode.supernode == nil { self.offsetContainerNode.addSubnode(self.downloadStatusIconNode) } self.downloadStatusIconNode.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(item.presentationData.theme.theme) } } else { if let linearProgressNode = self.linearProgressNode { self.linearProgressNode = nil linearProgressNode.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, removeOnCompletion: false, completion: { [weak linearProgressNode] _ in linearProgressNode?.removeFromSupernode() }) } if self.downloadStatusIconNode.supernode != nil { self.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) } if downloadingString != nil { self.descriptionProgressNode.isHidden = false self.descriptionNode.isHidden = true } else { self.descriptionProgressNode.isHidden = true self.descriptionNode.isHidden = false } let descriptionFont = Font.regular(floor(item.presentationData.fontSize.baseDisplaySize * 13.0 / 17.0)) 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: self.descriptionNode.frame.origin, size: descriptionSize)) } 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: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: if let item = self.item, let interaction = self.interaction { let _ = interaction.openMessage(item.message, .default) } } case .playbackStatus: if let context = self.context { context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: nil) } } } } override public func header() -> ListViewItemHeader? { return self.item?.header } 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: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: break } } } 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)) } }