import Foundation import UIKit import Display import AsyncDisplayKit import TelegramPresentationData import TextFormat import RadialStatusNode import AppBundle private let font = Font.regular(11.0) private let boldFont = Font.semibold(11.0) public enum ChatMessageInteractiveMediaDownloadState: Equatable { case remote case fetching(progress: Float?) case compactRemote case compactFetching(progress: Float) } public enum ChatMessageInteractiveMediaBadgeContent: Equatable { case text(inset: CGFloat, backgroundColor: UIColor, foregroundColor: UIColor, text: NSAttributedString) case mediaDownload(backgroundColor: UIColor, foregroundColor: UIColor, duration: String, size: String?, muted: Bool, active: Bool) public static func ==(lhs: ChatMessageInteractiveMediaBadgeContent, rhs: ChatMessageInteractiveMediaBadgeContent) -> Bool { switch lhs { case let .text(lhsInset, lhsBackgroundColor, lhsForegroundColor, lhsText): if case let .text(rhsInset, rhsBackgroundColor, rhsForegroundColor, rhsText) = rhs, lhsInset.isEqual(to: rhsInset), lhsBackgroundColor.isEqual(rhsBackgroundColor), lhsForegroundColor.isEqual(rhsForegroundColor), lhsText.isEqual(to: rhsText) { return true } else { return false } case let .mediaDownload(lhsBackgroundColor, lhsForegroundColor, lhsDuration, lhsSize, lhsMuted, lhsActive): if case let .mediaDownload(rhsBackgroundColor, rhsForegroundColor, rhsDuration, rhsSize, rhsMuted, rhsActive) = rhs, lhsBackgroundColor.isEqual(rhsBackgroundColor), lhsForegroundColor.isEqual(rhsForegroundColor), lhsDuration == rhsDuration, lhsSize == rhsSize, lhsMuted == rhsMuted, lhsActive == rhsActive { return true } else { return false } } } } public final class ChatMessageInteractiveMediaBadge: ASDisplayNode { private var content: ChatMessageInteractiveMediaBadgeContent? public var pressed: (() -> Void)? private var mediaDownloadState: ChatMessageInteractiveMediaDownloadState? private var previousContentSize: CGSize? private var backgroundNodeColor: UIColor? private var foregroundColor: UIColor? private let backgroundNode: ASImageNode private let durationNode: ASTextNode private var sizeNode: ASTextNode? private var measureNode: ASTextNode private var iconNode: ASImageNode? private var mediaDownloadStatusNode: RadialStatusNode? override public init() { self.backgroundNode = ASImageNode() self.backgroundNode.clipsToBounds = true self.durationNode = ASTextNode() self.measureNode = ASTextNode() super.init() self.addSubnode(self.backgroundNode) self.backgroundNode.addSubnode(self.durationNode) } override public func didLoad() { super.didLoad() self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.tapGesture(_:)))) } @objc private func tapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.pressed?() } } private let digitsSet = CharacterSet(charactersIn: "0123456789") private func widthForString(_ string: String) -> CGFloat { let convertedString = string.components(separatedBy: digitsSet).joined(separator: "8") self.measureNode.attributedText = NSMutableAttributedString(string: convertedString, attributes: [.font: font]) return self.measureNode.measure(CGSize(width: 240.0, height: 160.0)).width } public func update(theme: PresentationTheme?, content: ChatMessageInteractiveMediaBadgeContent?, mediaDownloadState: ChatMessageInteractiveMediaDownloadState?, alignment: NSTextAlignment = .left, animated: Bool, badgeAnimated: Bool = true) { var transition: ContainedViewLayoutTransition = animated ? .animated(duration: 0.2, curve: .easeInOut) : .immediate let previousContentSize = self.previousContentSize var contentSize: CGSize? if self.content != content { let previousContent = self.content self.content = content var currentContentSize = CGSize() if let content = self.content { var previousActive: Bool? var previousMuted: Bool? if let previousContent = previousContent, case let .mediaDownload(_, _, _, _, muted, active) = previousContent { previousActive = active previousMuted = muted } switch content { case let .text(inset, backgroundColor, foregroundColor, text): transition = .immediate if self.backgroundNodeColor != backgroundColor { self.backgroundNodeColor = backgroundColor self.backgroundNode.image = generateStretchableFilledCircleImage(radius: 9.0, color: backgroundColor) } let convertedText = NSMutableAttributedString(string: text.string, attributes: [.font: font, .foregroundColor: foregroundColor]) text.enumerateAttributes(in: NSRange(location: 0, length: text.length), options: []) { attributes, range, _ in if let _ = attributes[ChatTextInputAttributes.bold] { convertedText.addAttribute(.font, value: boldFont, range: range) } } self.durationNode.attributedText = convertedText let durationSize = self.durationNode.measure(CGSize(width: 160.0, height: 160.0)) self.durationNode.frame = CGRect(x: 7.0 + inset, y: 3.0, width: durationSize.width, height: durationSize.height) currentContentSize = CGSize(width: widthForString(text.string) + 14.0 + inset, height: 18.0) if let iconNode = self.iconNode { transition.updateTransformScale(node: iconNode, scale: 0.001) transition.updateAlpha(node: iconNode, alpha: 0.0) } case let .mediaDownload(backgroundColor, foregroundColor, duration, size, muted, active): if self.backgroundNodeColor != backgroundColor { self.backgroundNodeColor = backgroundColor self.backgroundNode.image = generateStretchableFilledCircleImage(radius: 9.0, color: backgroundColor) } if previousActive == nil { previousActive = active } if previousMuted == nil { previousMuted = muted } let textTransition = previousActive != active ? transition : .immediate transition = (previousMuted != muted || previousActive != active) ? transition : .immediate let durationString = NSMutableAttributedString(string: duration, attributes: [.font: font, .foregroundColor: foregroundColor]) self.durationNode.attributedText = durationString var sizeWidth: CGFloat = 0.0 if let size = size { let sizeNode: ASTextNode if let current = self.sizeNode { sizeNode = current } else { sizeNode = ASTextNode() self.sizeNode = sizeNode self.backgroundNode.addSubnode(sizeNode) } let sizeString = NSMutableAttributedString(string: size, attributes: [.font: font, .foregroundColor: foregroundColor]) sizeWidth = widthForString(size) sizeNode.attributedText = sizeString let sizeSize = sizeNode.measure(CGSize(width: 160.0, height: 160.0)) let sizeFrame = CGRect(x: active ? 42.0 : 7.0, y: active ? 19.0 : 2.0, width: sizeSize.width, height: sizeSize.height) sizeNode.bounds = CGRect(origin: CGPoint(), size: sizeFrame.size) let previousFrame = sizeNode.frame if previousFrame.center.y != sizeFrame.center.y { textTransition.updatePosition(node: sizeNode, position: sizeFrame.center) } else { sizeNode.layer.removeAllAnimations() sizeNode.frame = sizeFrame } transition.updateAlpha(node: sizeNode, alpha: 1.0) } else if let sizeNode = self.sizeNode { let sizeSize = sizeNode.frame.size let sizeFrame = CGRect(x: active ? 42.0 : 7.0, y: active ? 19.0 : 2.0, width: sizeSize.width, height: sizeSize.height) sizeNode.bounds = CGRect(origin: CGPoint(), size: sizeFrame.size) textTransition.updatePosition(node: sizeNode, position: sizeFrame.center) transition.updateAlpha(node: sizeNode, alpha: 0.0) } let durationSize = self.durationNode.measure(CGSize(width: 160.0, height: 160.0)) if let statusNode = self.mediaDownloadStatusNode { transition.updateAlpha(node: statusNode, alpha: active ? 1.0 : 0.0) } let durationFrame = CGRect(x: active ? 42.0 : 7.0, y: active ? 6.0 : 3.0, width: durationSize.width, height: durationSize.height) self.durationNode.bounds = CGRect(origin: CGPoint(), size: durationFrame.size) textTransition.updatePosition(node: self.durationNode, position: durationFrame.center) let iconNode: ASImageNode if let current = self.iconNode { iconNode = current } else { iconNode = ASImageNode() iconNode.frame = CGRect(x: 0.0, y: 0.0, width: 14.0, height: 9.0) self.iconNode = iconNode self.backgroundNode.addSubnode(iconNode) } if self.foregroundColor != foregroundColor { self.foregroundColor = foregroundColor iconNode.image = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/InlineVideoMute"), color: foregroundColor) } let durationWidth = widthForString(duration) transition.updatePosition(node: iconNode, position: CGPoint(x: (active ? 42.0 : 7.0) + durationWidth + 4.0 + 7.0, y: (active ? 8.0 : 4.0) + 5.0)) if muted { transition.updateAlpha(node: iconNode, alpha: 1.0) transition.updateTransformScale(node: iconNode, scale: 1.0) } else if let iconNode = self.iconNode { transition.updateAlpha(node: iconNode, alpha: 0.0) transition.updateTransformScale(node: iconNode, scale: 0.001) } var contentWidth: CGFloat = max(sizeWidth, durationWidth + (muted ? 17.0 : 0.0)) + 14.0 if active { contentWidth += 36.0 } currentContentSize = CGSize(width: contentWidth, height: active ? 38.0 : 18.0) } } var originX: CGFloat = 0.0 if alignment == .right { originX = -currentContentSize.width } let previousSize = self.backgroundNode.frame.size if previousSize.height == 0 || (previousSize.height == currentContentSize.height && currentContentSize.height == 38.0) { self.backgroundNode.frame = CGRect(x: originX, y: 0.0, width: currentContentSize.width, height: currentContentSize.height) } else { transition.updateFrame(node: self.backgroundNode, frame: CGRect(x: originX, y: 0.0, width: currentContentSize.width, height: currentContentSize.height)) } contentSize = currentContentSize self.previousContentSize = contentSize } else { contentSize = previousContentSize } if self.mediaDownloadState != mediaDownloadState || previousContentSize != contentSize { self.mediaDownloadState = mediaDownloadState if let mediaDownloadState = self.mediaDownloadState, let contentSize = contentSize { let mediaDownloadStatusNode: RadialStatusNode if let current = self.mediaDownloadStatusNode { mediaDownloadStatusNode = current } else { mediaDownloadStatusNode = RadialStatusNode(backgroundNodeColor: .clear) self.mediaDownloadStatusNode = mediaDownloadStatusNode self.addSubnode(mediaDownloadStatusNode) } let state: RadialStatusNodeState var isCompact = false var originX: CGFloat = 0.0 if alignment == .right { originX -= contentSize.width } var originY: CGFloat = 5.0 switch mediaDownloadState { case .remote: if let theme = theme, let image = PresentationResourcesChat.chatBubbleFileCloudFetchMediaIcon(theme) { state = .customIcon(image) } else { state = .none } case let .fetching(progress): var cloudProgress: CGFloat? if let progress = progress { cloudProgress = CGFloat(progress) } state = .cloudProgress(color: .white, strokeBackgroundColor: UIColor(white: 1.0, alpha: 0.3), lineWidth: 2.0 - UIScreenPixel, value: cloudProgress) case .compactRemote: state = .download(.white) isCompact = true originY = -1.0 - UIScreenPixel case .compactFetching: state = .progress(color: .white, lineWidth: nil, value: 0.0, cancelEnabled: true, animateRotation: true) isCompact = true originY = -1.0 } let mediaStatusFrame: CGRect if isCompact { mediaStatusFrame = CGRect(origin: CGPoint(x: 1.0 + originX, y: originY), size: CGSize(width: 20.0, height: 20.0)) } else { mediaStatusFrame = CGRect(origin: CGPoint(x: 7.0 + originX, y: originY), size: CGSize(width: 28.0, height: 28.0)) } mediaDownloadStatusNode.frame = mediaStatusFrame mediaDownloadStatusNode.transitionToState(state, animated: badgeAnimated, completion: {}) } else if let mediaDownloadStatusNode = self.mediaDownloadStatusNode { mediaDownloadStatusNode.transitionToState(.none, animated: badgeAnimated, completion: {}) } } } override public func point(inside point: CGPoint, with event: UIEvent?) -> Bool { return self.backgroundNode.frame.contains(point) } }