import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import TelegramPresentationData import LocalizedPeerData import AccountContext import AvatarNode public enum ChatMessageForwardInfoType: Equatable { case bubble(incoming: Bool) case standalone } private final class InfoButtonNode: HighlightableButtonNode { private let pressed: () -> Void let iconNode: ASImageNode private var theme: ChatPresentationThemeData? private var type: ChatMessageForwardInfoType? init(pressed: @escaping () -> Void) { self.pressed = pressed self.iconNode = ASImageNode() self.iconNode.displaysAsynchronously = false super.init() self.addSubnode(self.iconNode) self.addTarget(self, action: #selector(self.pressedEvent), forControlEvents: .touchUpInside) } @objc private func pressedEvent() { self.pressed() } func update(size: CGSize, theme: ChatPresentationThemeData, type: ChatMessageForwardInfoType) { if self.theme !== theme || self.type != type { self.theme = theme self.type = type let color: UIColor switch type { case let .bubble(incoming): color = incoming ? theme.theme.chat.message.incoming.accentControlColor : theme.theme.chat.message.outgoing.accentControlColor case .standalone: let serviceColor = serviceMessageColorComponents(theme: theme.theme, wallpaper: theme.wallpaper) color = serviceColor.primaryText } self.iconNode.image = PresentationResourcesChat.chatPsaInfo(theme.theme, color: color.argb) } if let image = self.iconNode.image { self.iconNode.frame = CGRect(origin: CGPoint(x: floor((size.width - image.size.width) / 2.0), y: floor((size.height - image.size.height) / 2.0)), size: image.size) } } } public class ChatMessageForwardInfoNode: ASDisplayNode { public enum StoryType { case regular case expired case unavailable } public struct StoryData: Equatable { public var storyType: StoryType public init(storyType: StoryType) { self.storyType = storyType } } public private(set) var titleNode: TextNode? public private(set) var nameNode: TextNode? private var credibilityIconNode: ASImageNode? private var infoNode: InfoButtonNode? private var expiredStoryIconView: UIImageView? private var avatarNode: AvatarNode? private var theme: PresentationTheme? private var highlightColor: UIColor? private var linkHighlightingNode: LinkHighlightingNode? public var openPsa: ((String, ASDisplayNode) -> Void)? override public init() { super.init() } public func hasAction(at point: CGPoint) -> Bool { if let infoNode = self.infoNode, infoNode.frame.contains(point) { return true } else { return false } } public func updatePsaButtonDisplay(isVisible: Bool, animated: Bool) { if let infoNode = self.infoNode { if isVisible != !infoNode.iconNode.alpha.isZero { let transition: ContainedViewLayoutTransition if animated { transition = .animated(duration: 0.25, curve: .easeInOut) } else { transition = .immediate } transition.updateAlpha(node: infoNode.iconNode, alpha: isVisible ? 1.0 : 0.0) transition.updateSublayerTransformScale(node: infoNode, scale: isVisible ? 1.0 : 0.1) } } } public func updateTouchesAtPoint(_ point: CGPoint?) { var isHighlighted = false if point != nil { isHighlighted = true } var initialRects: [CGRect] = [] let addRects: (TextNode, CGPoint, CGFloat) -> Void = { textNode, offset, additionalWidth in guard let cachedLayout = textNode.cachedLayout else { return } for rect in cachedLayout.linesRects() { var rect = rect rect.size.width += rect.origin.x + additionalWidth rect.origin.x = 0.0 initialRects.append(rect.offsetBy(dx: offset.x, dy: offset.y)) } } let offsetY: CGFloat = -12.0 if let titleNode = self.titleNode { addRects(titleNode, CGPoint(x: titleNode.frame.minX, y: offsetY + titleNode.frame.minY), 0.0) if let nameNode = self.nameNode { addRects(nameNode, CGPoint(x: titleNode.frame.minX, y: offsetY + nameNode.frame.minY), nameNode.frame.minX - titleNode.frame.minX) } } if isHighlighted, !initialRects.isEmpty, let highlightColor = self.highlightColor { let rects = initialRects let linkHighlightingNode: LinkHighlightingNode if let current = self.linkHighlightingNode { linkHighlightingNode = current } else { linkHighlightingNode = LinkHighlightingNode(color: highlightColor) self.linkHighlightingNode = linkHighlightingNode self.addSubnode(linkHighlightingNode) } linkHighlightingNode.frame = self.bounds linkHighlightingNode.updateRects(rects) } else if let linkHighlightingNode = self.linkHighlightingNode { self.linkHighlightingNode = nil linkHighlightingNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false, completion: { [weak linkHighlightingNode] _ in linkHighlightingNode?.removeFromSupernode() }) } } public static func asyncLayout(_ maybeNode: ChatMessageForwardInfoNode?) -> (_ context: AccountContext, _ presentationData: ChatPresentationData, _ strings: PresentationStrings, _ type: ChatMessageForwardInfoType, _ peer: Peer?, _ authorName: String?, _ psaType: String?, _ storyData: StoryData?, _ constrainedSize: CGSize) -> (CGSize, (CGFloat) -> ChatMessageForwardInfoNode) { let titleNodeLayout = TextNode.asyncLayout(maybeNode?.titleNode) let nameNodeLayout = TextNode.asyncLayout(maybeNode?.nameNode) return { context, presentationData, strings, type, peer, authorName, psaType, storyData, constrainedSize in let fontSize = floor(presentationData.fontSize.baseDisplaySize * 14.0 / 17.0) let prefixFont = Font.regular(fontSize) let peerFont = Font.medium(fontSize) let peerString: String if let peer = peer { if let authorName = authorName { peerString = "\(EnginePeer(peer).displayTitle(strings: strings, displayOrder: presentationData.nameDisplayOrder)) (\(authorName))" } else { peerString = EnginePeer(peer).displayTitle(strings: strings, displayOrder: presentationData.nameDisplayOrder) } } else if let authorName = authorName { peerString = authorName } else { peerString = "" } var hasPsaInfo = false if let _ = psaType { hasPsaInfo = true } let titleColor: UIColor let titleString: PresentationStrings.FormattedString var authorString: String? switch type { case let .bubble(incoming): if let psaType = psaType { titleColor = incoming ? presentationData.theme.theme.chat.message.incoming.polls.barPositive : presentationData.theme.theme.chat.message.outgoing.polls.barPositive var customFormat: String? let key = "Message.ForwardedPsa.\(psaType)" if let string = presentationData.strings.primaryComponent.dict[key] { customFormat = string } else if let string = presentationData.strings.secondaryComponent?.dict[key] { customFormat = string } if let customFormat = customFormat { if let range = customFormat.range(of: "%@") { let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) let rightPart = String(customFormat[range.upperBound...]) let formattedText = leftPart + peerString + rightPart titleString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) } else { titleString = PresentationStrings.FormattedString(string: customFormat, ranges: []) } } else { titleString = strings.Message_GenericForwardedPsa(peerString) } } else { if incoming { if let nameColor = peer?.nameColor { titleColor = context.peerNameColors.get(nameColor, dark: presentationData.theme.theme.overallDarkAppearance).main } else { titleColor = presentationData.theme.theme.chat.message.incoming.accentTextColor } } else { titleColor = presentationData.theme.theme.chat.message.outgoing.accentTextColor } //TODO:localize if let storyData = storyData { switch storyData.storyType { case .regular: titleString = PresentationStrings.FormattedString(string: "Forwarded story from", ranges: []) authorString = peerString case .expired: titleString = PresentationStrings.FormattedString(string: "Expired story from", ranges: []) authorString = peerString case .unavailable: titleString = PresentationStrings.FormattedString(string: "Expired story from", ranges: []) authorString = peerString } } else { titleString = PresentationStrings.FormattedString(string: "Forwarded from", ranges: []) authorString = peerString } } case .standalone: let serviceColor = serviceMessageColorComponents(theme: presentationData.theme.theme, wallpaper: presentationData.theme.wallpaper) titleColor = serviceColor.primaryText if let psaType = psaType { var customFormat: String? let key = "Message.ForwardedPsa.\(psaType)" if let string = presentationData.strings.primaryComponent.dict[key] { customFormat = string } else if let string = presentationData.strings.secondaryComponent?.dict[key] { customFormat = string } if let customFormat = customFormat { if let range = customFormat.range(of: "%@") { let leftPart = String(customFormat[customFormat.startIndex ..< range.lowerBound]) let rightPart = String(customFormat[range.upperBound...]) let formattedText = leftPart + peerString + rightPart titleString = PresentationStrings.FormattedString(string: formattedText, ranges: [PresentationStrings.FormattedString.Range(index: 0, range: NSRange(location: leftPart.count, length: peerString.count))]) } else { titleString = PresentationStrings.FormattedString(string: customFormat, ranges: []) } } else { titleString = strings.Message_GenericForwardedPsa(peerString) } } else { titleString = PresentationStrings.FormattedString(string: "Forwarded from", ranges: []) authorString = peerString } } var currentCredibilityIconImage: UIImage? var highlight = true if let peer = peer { if let channel = peer as? TelegramChannel, channel.addressName == nil { if case let .broadcast(info) = channel.info, info.flags.contains(.hasDiscussionGroup) { } else if case .member = channel.participationStatus { } else { highlight = false } } if peer.isFake { switch type { case let .bubble(incoming): currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) case .standalone: currentCredibilityIconImage = PresentationResourcesChatList.fakeIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) } } else if peer.isScam { switch type { case let .bubble(incoming): currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: incoming ? .regular : .outgoing) case .standalone: currentCredibilityIconImage = PresentationResourcesChatList.scamIcon(presentationData.theme.theme, strings: presentationData.strings, type: .service) } } else { currentCredibilityIconImage = nil } } else { highlight = false } let rawTitleString: NSString = titleString.string as NSString let string = NSMutableAttributedString(string: rawTitleString as String, attributes: [NSAttributedString.Key.foregroundColor: titleColor, NSAttributedString.Key.font: prefixFont]) if highlight, let range = titleString.ranges.first?.range { string.addAttributes([NSAttributedString.Key.font: peerFont], range: range) } var credibilityIconWidth: CGFloat = 0.0 if let icon = currentCredibilityIconImage { credibilityIconWidth += icon.size.width + 4.0 } var infoWidth: CGFloat = 0.0 if hasPsaInfo { infoWidth += 32.0 } let leftOffset: CGFloat = 0.0 infoWidth += leftOffset var cutout: TextNodeCutout? if let storyData { switch storyData.storyType { case .regular, .unavailable: break case .expired: cutout = TextNodeCutout(topLeft: CGSize(width: 16.0, height: 10.0)) } } let (titleLayout, titleApply) = titleNodeLayout(TextNodeLayoutArguments(attributedString: string, backgroundColor: nil, maximumNumberOfLines: 2, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth, height: constrainedSize.height), alignment: .natural, cutout: cutout, insets: UIEdgeInsets())) var nameLayoutAndApply: (TextNodeLayout, () -> TextNode)? if let authorString { nameLayoutAndApply = nameNodeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: authorString, font: peerFont, textColor: titleColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth, height: constrainedSize.height), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) } let titleAuthorSpacing: CGFloat = 0.0 var authorAvatarInset: CGFloat = 0.0 if peer != nil { authorAvatarInset = 19.0 } let resultSize: CGSize if let nameLayoutAndApply { resultSize = CGSize( width: max( titleLayout.size.width + credibilityIconWidth + infoWidth, authorAvatarInset + nameLayoutAndApply.0.size.width ), height: titleLayout.size.height + titleAuthorSpacing + nameLayoutAndApply.0.size.height ) } else { resultSize = CGSize(width: titleLayout.size.width + credibilityIconWidth + infoWidth, height: titleLayout.size.height) } return (resultSize, { width in let node: ChatMessageForwardInfoNode if let maybeNode = maybeNode { node = maybeNode } else { node = ChatMessageForwardInfoNode() } node.theme = presentationData.theme.theme node.highlightColor = titleColor.withMultipliedAlpha(0.1) let titleNode = titleApply() titleNode.displaysAsynchronously = !presentationData.isPreview if node.titleNode == nil { titleNode.isUserInteractionEnabled = false node.titleNode = titleNode node.addSubnode(titleNode) } titleNode.frame = CGRect(origin: CGPoint(x: leftOffset, y: 0.0), size: titleLayout.size) if let (nameLayout, nameApply) = nameLayoutAndApply { let nameNode = nameApply() if node.nameNode == nil { nameNode.isUserInteractionEnabled = false node.nameNode = nameNode node.addSubnode(nameNode) } nameNode.frame = CGRect(origin: CGPoint(x: leftOffset + authorAvatarInset, y: titleLayout.size.height + titleAuthorSpacing), size: nameLayout.size) if let peer, authorAvatarInset != 0.0 { let avatarNode: AvatarNode if let current = node.avatarNode { avatarNode = current } else { avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 8.0)) node.avatarNode = avatarNode node.addSubnode(avatarNode) } let avatarSize = CGSize(width: 16.0, height: 16.0) avatarNode.frame = CGRect(origin: CGPoint(x: leftOffset, y: titleLayout.size.height + titleAuthorSpacing), size: avatarSize) avatarNode.updateSize(size: avatarSize) avatarNode.setPeer(context: context, theme: presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: avatarSize) } else { if let avatarNode = node.avatarNode { node.avatarNode = nil avatarNode.removeFromSupernode() } } } else { if let nameNode = node.nameNode { node.nameNode = nil nameNode.removeFromSupernode() } if let avatarNode = node.avatarNode { node.avatarNode = nil avatarNode.removeFromSupernode() } } if let storyData, case .expired = storyData.storyType { let expiredStoryIconView: UIImageView if let current = node.expiredStoryIconView { expiredStoryIconView = current } else { expiredStoryIconView = UIImageView() node.expiredStoryIconView = expiredStoryIconView node.view.addSubview(expiredStoryIconView) } let imageType: ChatExpiredStoryIndicatorType switch type { case .standalone: imageType = .free case let .bubble(incoming): imageType = incoming ? .incoming : .outgoing } expiredStoryIconView.image = PresentationResourcesChat.chatExpiredStoryIndicatorIcon(presentationData.theme.theme, type: imageType) if let _ = expiredStoryIconView.image { let imageSize = CGSize(width: 18.0, height: 18.0) expiredStoryIconView.frame = CGRect(origin: CGPoint(x: -1.0, y: -2.0), size: imageSize) } } else if let expiredStoryIconView = node.expiredStoryIconView { expiredStoryIconView.removeFromSuperview() } if let credibilityIconImage = currentCredibilityIconImage { let credibilityIconNode: ASImageNode if let node = node.credibilityIconNode { credibilityIconNode = node } else { credibilityIconNode = ASImageNode() node.credibilityIconNode = credibilityIconNode node.addSubnode(credibilityIconNode) } credibilityIconNode.frame = CGRect(origin: CGPoint(x: titleLayout.size.width + 4.0, y: 16.0), size: credibilityIconImage.size) credibilityIconNode.image = credibilityIconImage } else { node.credibilityIconNode?.removeFromSupernode() node.credibilityIconNode = nil } if hasPsaInfo { let infoNode: InfoButtonNode if let current = node.infoNode { infoNode = current } else { infoNode = InfoButtonNode(pressed: { [weak node] in guard let node = node else { return } if let psaType = psaType, let infoNode = node.infoNode { node.openPsa?(psaType, infoNode) } }) node.infoNode = infoNode node.addSubnode(infoNode) } let infoButtonSize = CGSize(width: 32.0, height: 32.0) let infoButtonFrame = CGRect(origin: CGPoint(x: width - infoButtonSize.width - 2.0, y: 1.0), size: infoButtonSize) infoNode.frame = infoButtonFrame infoNode.update(size: infoButtonFrame.size, theme: presentationData.theme, type: type) } else if let infoNode = node.infoNode { node.infoNode = nil infoNode.removeFromSupernode() } return node }) } } }