2024-04-23 13:37:42 +04:00

542 lines
26 KiB
Swift

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?
private var previousPeer: Peer?
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 getBoundingRects() -> [CGRect] {
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)
}
}
return initialRects
}
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)
let previousPeer = maybeNode?.previousPeer
return { context, presentationData, strings, type, peer, authorName, psaType, storyData, constrainedSize in
let originalPeer = peer
let peer = peer ?? previousPeer
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, originalPeer === peer {
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
}
if let storyData = storyData {
switch storyData.storyType {
case .regular:
titleString = PresentationStrings.FormattedString(string: presentationData.strings.Chat_MessageForwardInfo_StoryHeader, ranges: [])
authorString = peerString
case .expired:
titleString = PresentationStrings.FormattedString(string: presentationData.strings.Chat_MessageForwardInfo_ExpiredStoryHeader, ranges: [])
authorString = peerString
case .unavailable:
titleString = PresentationStrings.FormattedString(string: presentationData.strings.Chat_MessageForwardInfo_UnavailableStoryHeader, ranges: [])
authorString = peerString
}
} else {
titleString = PresentationStrings.FormattedString(string: presentationData.strings.Chat_MessageForwardInfo_MessageHeader, 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: presentationData.strings.Chat_MessageForwardInfo_MessageHeader, 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 authorAvatarInset: CGFloat = 0.0
authorAvatarInset = 20.0
var nameLayoutAndApply: (TextNodeLayout, () -> TextNode)?
if let authorString {
nameLayoutAndApply = nameNodeLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: authorString, font: peer != nil ? peerFont : prefixFont, textColor: titleColor), backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: constrainedSize.width - credibilityIconWidth - infoWidth - authorAvatarInset, height: constrainedSize.height), alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
}
let titleAuthorSpacing: CGFloat = 0.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)
node.previousPeer = peer
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 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)
if let peer {
avatarNode.setPeer(context: context, theme: presentationData.theme.theme, peer: EnginePeer(peer), displayDimensions: avatarSize)
} else if let authorName, !authorName.isEmpty {
avatarNode.setCustomLetters([String(authorName[authorName.startIndex])])
} else {
avatarNode.setCustomLetters([" "])
}
} 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
})
}
}
}