import Foundation import AsyncDisplayKit import Postbox import SwiftSignalKit import Display import TelegramCore private struct FetchControls { let fetch: () -> Void let cancel: () -> Void } private let titleFont = Font.regular(16.0) private let descriptionFont = Font.regular(13.0) private let incomingTitleColor = UIColor(0x0b8bed) private let outgoingTitleColor = UIColor(0x3faa3c) private let incomingDescriptionColor = UIColor(0x999999) private let outgoingDescriptionColor = UIColor(0x6fb26a) private let fileIconIncomingImage = UIImage(bundleImageName: "Chat/Message/RadialProgressIconDocumentIncoming")?.precomposed() private let fileIconOutgoingImage = UIImage(bundleImageName: "Chat/Message/RadialProgressIconDocumentOutgoing")?.precomposed() final class ChatMessageInteractiveFileNode: ASTransformNode { private let titleNode: TextNode private let descriptionNode: TextNode private var iconNode: TransformImageNode? private var progressNode: RadialProgressNode? private var tapRecognizer: UITapGestureRecognizer? private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) private var fetchStatus: MediaResourceStatus? private let fetchDisposable = MetaDisposable() var activateLocalContent: () -> Void = { } private var messageIdAndFlags: (MessageId, MessageFlags)? private var file: TelegramMediaFile? init() { self.titleNode = TextNode() self.titleNode.displaysAsynchronously = true self.titleNode.isLayerBacked = true self.descriptionNode = TextNode() self.descriptionNode.displaysAsynchronously = true self.descriptionNode.isLayerBacked = true super.init(layerBacked: false) self.addSubnode(self.titleNode) self.addSubnode(self.descriptionNode) } deinit { self.statusDisposable.dispose() self.fetchDisposable.dispose() } override func didLoad() { let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(self.fileTap(_:))) self.view.addGestureRecognizer(tapRecognizer) self.tapRecognizer = tapRecognizer } @objc func progressPressed() { if let fetchStatus = self.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: break } } } @objc func fileTap(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { self.activateLocalContent() } else { self.progressPressed() } } } func asyncLayout() -> (_ account: Account, _ message: Message, _ file: TelegramMediaFile, _ incoming: Bool, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) { let currentFile = self.file let titleAsyncLayout = TextNode.asyncLayout(self.titleNode) let descriptionAsyncLayout = TextNode.asyncLayout(self.descriptionNode) let currentMessageIdAndFlags = self.messageIdAndFlags return { account, message, file, incoming, constrainedSize in return (CGFloat.greatestFiniteMagnitude, { constrainedSize in //var updateImageSignal: Signal DrawingContext, NoError>? var updatedStatusSignal: Signal? var updatedFetchControls: FetchControls? var mediaUpdated = false if let currentFile = currentFile { mediaUpdated = file != currentFile } else { mediaUpdated = true } var statusUpdated = mediaUpdated if currentMessageIdAndFlags?.0 != message.id || currentMessageIdAndFlags?.1 != message.flags { statusUpdated = true } if mediaUpdated { updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(chatMessageFileInteractiveFetched(account: account, file: file).start()) } }, cancel: { chatMessageFileCancelInteractiveFetch(account: account, file: file) }) } if statusUpdated { updatedStatusSignal = combineLatest(chatMessageFileStatus(account: account, file: file), account.pendingMessageManager.pendingMessageStatus(message.id)) |> map { resourceStatus, pendingStatus -> MediaResourceStatus in if let pendingStatus = pendingStatus { return .Fetching(progress: pendingStatus.progress) } else { return resourceStatus } } } var candidateTitleString: NSAttributedString? var candidateDescriptionString: NSAttributedString? for attribute in file.attributes { if case let .Audio(_, _, title, performer, _) = attribute { candidateTitleString = NSAttributedString(string: title ?? "Unknown Track", font: titleFont, textColor: incoming ? incomingTitleColor : outgoingTitleColor) candidateDescriptionString = NSAttributedString(string: performer ?? dataSizeString(file.size), font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) break } } var titleString: NSAttributedString let descriptionString: NSAttributedString if let candidateTitleString = candidateTitleString { titleString = candidateTitleString } else { titleString = NSAttributedString(string: file.fileName ?? "File", font: titleFont, textColor: incoming ? incomingTitleColor : outgoingTitleColor) } if let candidateDescriptionString = candidateDescriptionString { descriptionString = candidateDescriptionString } else { descriptionString = NSAttributedString(string: dataSizeString(file.size), font: descriptionFont, textColor:incoming ? incomingDescriptionColor : outgoingDescriptionColor) } let textConstrainedSize = CGSize(width: constrainedSize.width - 44.0 - 8.0, height: constrainedSize.height) let (titleLayout, titleApply) = titleAsyncLayout(titleString, nil, 1, .middle, textConstrainedSize, nil) let (descriptionLayout, descriptionApply) = descriptionAsyncLayout(descriptionString, nil, 1, .middle, textConstrainedSize, nil) return (max(titleLayout.size.width, descriptionLayout.size.width) + 44.0 + 8.0, { boundingWidth in let progressFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 44.0, height: 44.0)) let titleAndDescriptionHeight = titleLayout.size.height - 1.0 + descriptionLayout.size.height let titleFrame = CGRect(origin: CGPoint(x: progressFrame.maxX + 8.0, y: floor((44.0 - titleAndDescriptionHeight) / 2.0)), size: titleLayout.size) let descriptionFrame = CGRect(origin: CGPoint(x: titleFrame.minX, y: titleFrame.maxY - 1.0), size: descriptionLayout.size) return (titleFrame.union(descriptionFrame).union(progressFrame).size, { [weak self] in if let strongSelf = self { strongSelf.messageIdAndFlags = (message.id, message.flags) strongSelf.file = file let _ = titleApply() let _ = descriptionApply() strongSelf.titleNode.frame = titleFrame strongSelf.descriptionNode.frame = descriptionFrame /*if let updateImageSignal = updateImageSignal { strongSelf.imageNode.setSignal(account, signal: updateImageSignal) }*/ if let updatedStatusSignal = updatedStatusSignal { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { strongSelf.fetchStatus = status if strongSelf.progressNode == nil { let progressNode = RadialProgressNode(theme: RadialProgressTheme(backgroundColor: UIColor(incoming ? 0x007ee5 : 0x3fc33b), foregroundColor: incoming ? UIColor.white : UIColor(0xe1ffc7), icon: incoming ? fileIconIncomingImage : fileIconOutgoingImage)) strongSelf.progressNode = progressNode progressNode.frame = progressFrame strongSelf.addSubnode(progressNode) } switch status { case let .Fetching(progress): strongSelf.progressNode?.state = .Fetching(progress: progress) case .Local: strongSelf.progressNode?.state = .Play case .Remote: strongSelf.progressNode?.state = .Remote } } } })) } strongSelf.progressNode?.frame = progressFrame if let updatedFetchControls = updatedFetchControls { let _ = strongSelf.fetchControls.swap(updatedFetchControls) } } }) }) }) } } static func asyncLayout(_ node: ChatMessageInteractiveFileNode?) -> (_ account: Account, _ message: Message, _ file: TelegramMediaFile, _ incoming: Bool, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> ChatMessageInteractiveFileNode))) { let currentAsyncLayout = node?.asyncLayout() return { account, message, file, incoming, constrainedSize in var fileNode: ChatMessageInteractiveFileNode var fileLayout: (_ account: Account, _ message: Message, _ file: TelegramMediaFile, _ incoming: Bool, _ constrainedSize: CGSize) -> (CGFloat, (CGSize) -> (CGFloat, (CGFloat) -> (CGSize, () -> Void))) if let node = node, let currentAsyncLayout = currentAsyncLayout { fileNode = node fileLayout = currentAsyncLayout } else { fileNode = ChatMessageInteractiveFileNode() fileLayout = fileNode.asyncLayout() } let (initialWidth, continueLayout) = fileLayout(account, message, file, incoming, constrainedSize) return (initialWidth, { constrainedSize in let (finalWidth, finalLayout) = continueLayout(constrainedSize) return (finalWidth, { boundingWidth in let (finalSize, apply) = finalLayout(boundingWidth) return (finalSize, { apply() return fileNode }) }) }) } } }