2025-04-08 14:38:46 +04:00

435 lines
23 KiB
Swift

import Foundation
import UIKit
import AsyncDisplayKit
import Display
import TelegramCore
import Postbox
import TelegramPresentationData
import AppBundle
import ChatMessageBubbleContentNode
import ChatMessageItemCommon
import ChatMessageDateAndStatusNode
import SwiftSignalKit
import AnimatedAvatarSetNode
import AvatarNode
private let titleFont: UIFont = Font.medium(16.0)
private let labelFont: UIFont = Font.regular(13.0)
private let avatarFont: UIFont = avatarPlaceholderFont(size: 8.0)
private let incomingGreenIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/CallIncomingArrow"), color: UIColor(rgb: 0x36c033))
private let incomingRedIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/CallIncomingArrow"), color: UIColor(rgb: 0xff4747))
private let outgoingGreenIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/CallOutgoingArrow"), color: UIColor(rgb: 0x36c033))
private let outgoingRedIcon = generateTintedImage(image: UIImage(bundleImageName: "Chat/Message/CallOutgoingArrow"), color: UIColor(rgb: 0xff4747))
public class ChatMessageCallBubbleContentNode: ChatMessageBubbleContentNode {
private let titleNode: TextNode
private let labelNode: TextNode
private var peopleAvatarsContext: AnimatedAvatarSetContext?
private var peopleAvatarsNode: AnimatedAvatarSetNode?
private var peopleTextNode: TextNode?
private let iconNode: ASImageNode
private let buttonNode: HighlightableButtonNode
private var activeConferenceUpdateTimer: SwiftSignalKit.Timer?
required public init() {
self.titleNode = TextNode()
self.labelNode = TextNode()
self.iconNode = ASImageNode()
self.iconNode.displayWithoutProcessing = true
self.iconNode.displaysAsynchronously = false
self.iconNode.isLayerBacked = true
self.buttonNode = HighlightableButtonNode()
self.buttonNode.isAccessibilityElement = false
super.init()
self.titleNode.isUserInteractionEnabled = false
self.titleNode.contentMode = .topLeft
self.titleNode.contentsScale = UIScreenScale
self.titleNode.displaysAsynchronously = false
self.addSubnode(self.titleNode)
self.labelNode.isUserInteractionEnabled = false
self.labelNode.contentMode = .topLeft
self.labelNode.contentsScale = UIScreenScale
self.labelNode.displaysAsynchronously = false
self.addSubnode(self.labelNode)
self.addSubnode(self.iconNode)
self.addSubnode(self.buttonNode)
self.buttonNode.addTarget(self, action: #selector(self.callButtonPressed), forControlEvents: .touchUpInside)
}
deinit {
self.activeConferenceUpdateTimer?.invalidate()
}
override public func accessibilityActivate() -> Bool {
self.callButtonPressed()
return true
}
override public func didLoad() {
super.didLoad()
self.view.accessibilityElementsHidden = true
}
required public init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public func asyncLayoutContent() -> (_ item: ChatMessageBubbleContentItem, _ layoutConstants: ChatMessageItemLayoutConstants, _ preparePosition: ChatMessageBubblePreparePosition, _ messageSelection: Bool?, _ constrainedSize: CGSize, _ avatarInset: CGFloat) -> (ChatMessageBubbleContentProperties, CGSize?, CGFloat, (CGSize, ChatMessageBubbleContentPosition) -> (CGFloat, (CGFloat) -> (CGSize, (ListViewItemUpdateAnimation, Bool, ListViewItemApply?) -> Void))) {
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
let makePeopleTextLayout = TextNode.asyncLayout(self.peopleTextNode)
return { item, layoutConstants, _, _, _, _ in
let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none)
return (contentProperties, nil, CGFloat.greatestFiniteMagnitude, { constrainedSize, position in
let incoming = item.message.effectivelyIncoming(item.context.account.peerId)
let horizontalInset = layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
let textConstrainedSize = CGSize(width: constrainedSize.width - horizontalInset, height: constrainedSize.height)
let avatarsLeftInset: CGFloat = 5.0
let avatarsRightInset: CGFloat = 5.0
let peopleAvatarSize: CGFloat = 16.0
let peopleAvatarSpacing: CGFloat = 10.0
let messageTheme = incoming ? item.presentationData.theme.theme.chat.message.incoming : item.presentationData.theme.theme.chat.message.outgoing
var peopleTextString: String?
var peopleAvatars: [Peer] = []
var titleString: String?
var callDuration: Int32?
var callSuccessful = true
var isVideo = false
var hasCallButton = true
var updateConferenceTimerEndTimeout: Int32?
for media in item.message.media {
if let action = media as? TelegramMediaAction, case let .phoneCall(_, discardReason, duration, isVideoValue) = action.action {
isVideo = isVideoValue
callDuration = duration
if let discardReason = discardReason {
switch discardReason {
case .disconnect:
callSuccessful = false
if isVideo {
titleString = item.presentationData.strings.Notification_VideoCallCanceled
} else {
titleString = item.presentationData.strings.Notification_CallCanceled
}
case .missed, .busy:
callSuccessful = false
if incoming {
if isVideo {
titleString = item.presentationData.strings.Notification_VideoCallMissed
} else {
titleString = item.presentationData.strings.Notification_CallMissed
}
} else {
if isVideo {
titleString = item.presentationData.strings.Notification_VideoCallCanceled
} else {
titleString = item.presentationData.strings.Notification_CallCanceled
}
}
case .hangup:
break
}
}
break
} else if let action = media as? TelegramMediaAction, case let .conferenceCall(conferenceCall) = action.action {
isVideo = conferenceCall.flags.contains(.isVideo)
callDuration = conferenceCall.duration
if conferenceCall.otherParticipants.count > 0 {
//TODO:localize
peopleTextString = "\(conferenceCall.otherParticipants.count + 1) people"
if let peer = item.message.author {
peopleAvatars.append(peer)
}
for id in conferenceCall.otherParticipants {
if let peer = item.message.peers[id] {
peopleAvatars.append(peer)
}
}
}
//TODO:localize
let missedTimeout: Int32
#if DEBUG
missedTimeout = 5
#else
missedTimeout = 30
#endif
if conferenceCall.duration != nil {
hasCallButton = false
}
let currentTime = Int32(Date().timeIntervalSince1970)
if conferenceCall.flags.contains(.isMissed) {
titleString = "Declined Group Call"
} else if item.message.timestamp < currentTime - missedTimeout {
titleString = "Missed Group Call"
} else if conferenceCall.duration != nil {
titleString = "Cancelled Group Call"
} else {
if incoming {
titleString = "Incoming Group Call"
} else {
titleString = "Outgoing Group Call"
}
updateConferenceTimerEndTimeout = (item.message.timestamp + missedTimeout) - currentTime
}
break
}
}
if titleString == nil {
let baseString: String
if incoming {
if isVideo {
baseString = item.presentationData.strings.Notification_VideoCallIncoming
} else {
baseString = item.presentationData.strings.Notification_CallIncoming
}
} else {
if isVideo {
baseString = item.presentationData.strings.Notification_VideoCallOutgoing
} else {
baseString = item.presentationData.strings.Notification_CallOutgoing
}
}
titleString = baseString
}
let attributedTitle = NSAttributedString(string: titleString ?? "", font: titleFont, textColor: messageTheme.primaryTextColor)
var callIcon: UIImage?
if callSuccessful {
if incoming {
callIcon = incomingGreenIcon
} else {
callIcon = outgoingGreenIcon
}
} else {
if incoming {
callIcon = incomingRedIcon
} else {
callIcon = outgoingRedIcon
}
}
var buttonImage: UIImage?
if incoming {
if isVideo {
buttonImage = PresentationResourcesChat.chatBubbleIncomingVideoCallButtonImage(item.presentationData.theme.theme)
} else {
buttonImage = PresentationResourcesChat.chatBubbleIncomingCallButtonImage(item.presentationData.theme.theme)
}
} else {
if isVideo {
buttonImage = PresentationResourcesChat.chatBubbleOutgoingVideoCallButtonImage(item.presentationData.theme.theme)
} else {
buttonImage = PresentationResourcesChat.chatBubbleOutgoingCallButtonImage(item.presentationData.theme.theme)
}
}
let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, associatedData: item.associatedData)
var statusText: String
if let callDuration = callDuration, callDuration > 1 {
statusText = item.presentationData.strings.Notification_CallFormat(dateText, callDurationString(strings: item.presentationData.strings, value: callDuration)).string
} else {
statusText = dateText
}
if peopleTextString != nil || !peopleAvatars.isEmpty {
statusText.append(",")
}
let attributedLabel = NSAttributedString(string: statusText, font: labelFont, textColor: messageTheme.fileDurationColor)
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: attributedTitle, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
let (labelLayout, labelApply) = makeLabelLayout(TextNodeLayoutArguments(attributedString: attributedLabel, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
var peopleTextLayoutAndApply: (TextNodeLayout, () -> TextNode)?
if let peopleTextString {
peopleTextLayoutAndApply = makePeopleTextLayout(TextNodeLayoutArguments(attributedString: NSAttributedString(string: peopleTextString, font: labelFont, textColor: messageTheme.fileDurationColor), backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: textConstrainedSize, alignment: .natural, cutout: nil, insets: UIEdgeInsets()))
}
let titleSize = titleLayout.size
let labelSize = labelLayout.size
var titleFrame = CGRect(origin: CGPoint(), size: titleSize)
var labelFrame = CGRect(origin: CGPoint(x: 14.0, y: 0.0), size: labelSize)
titleFrame = titleFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top + 4.0)
labelFrame = labelFrame.offsetBy(dx: layoutConstants.text.bubbleInsets.left, dy: layoutConstants.text.bubbleInsets.top + titleSize.height + 4.0)
var boundingSize: CGSize
var labelsWidth: CGFloat = labelFrame.size.width
var avatarsWidth: CGFloat = 0.0
if !peopleAvatars.isEmpty {
avatarsWidth += avatarsLeftInset
avatarsWidth += 1.0 * peopleAvatarSize + CGFloat(peopleAvatars.count - 1) * peopleAvatarSpacing
avatarsWidth += avatarsRightInset
labelsWidth += avatarsWidth
}
if let peopleTextLayoutAndApply {
labelsWidth += peopleTextLayoutAndApply.0.size.width
}
boundingSize = CGSize(width: max(titleFrame.size.width, labelsWidth + 14.0), height: 47.0)
boundingSize.width += layoutConstants.text.bubbleInsets.left + layoutConstants.text.bubbleInsets.right
boundingSize.height += layoutConstants.text.bubbleInsets.top + layoutConstants.text.bubbleInsets.bottom
if hasCallButton {
boundingSize.width += 54.0
}
return (boundingSize.width, { boundingWidth in
return (boundingSize, { [weak self] animation, _, _ in
if let strongSelf = self {
strongSelf.item = item
let _ = titleApply()
let _ = labelApply()
strongSelf.titleNode.frame = titleFrame
strongSelf.labelNode.frame = labelFrame
if !peopleAvatars.isEmpty {
let peopleAvatarsContext: AnimatedAvatarSetContext
if let current = strongSelf.peopleAvatarsContext {
peopleAvatarsContext = current
} else {
peopleAvatarsContext = AnimatedAvatarSetContext()
strongSelf.peopleAvatarsContext = peopleAvatarsContext
}
let peopleAvatarsNode: AnimatedAvatarSetNode
if let current = strongSelf.peopleAvatarsNode {
peopleAvatarsNode = current
} else {
peopleAvatarsNode = AnimatedAvatarSetNode()
strongSelf.peopleAvatarsNode = peopleAvatarsNode
strongSelf.addSubnode(peopleAvatarsNode)
}
let peopleAvatarsContent = peopleAvatarsContext.update(peers: peopleAvatars.map(EnginePeer.init), animated: false)
let peopleAvatarsSize = peopleAvatarsNode.update(context: item.context, content: peopleAvatarsContent, itemSize: CGSize(width: peopleAvatarSize, height: peopleAvatarSize), customSpacing: peopleAvatarSize - peopleAvatarSpacing, font: avatarFont, animated: false, synchronousLoad: false)
peopleAvatarsNode.frame = CGRect(origin: CGPoint(x: labelFrame.maxX + avatarsLeftInset, y: labelFrame.minY - 1.0), size: peopleAvatarsSize)
} else {
strongSelf.peopleAvatarsContext = nil
if let peopleAvatarsNode = strongSelf.peopleAvatarsNode {
strongSelf.peopleAvatarsNode = nil
peopleAvatarsNode.removeFromSupernode()
}
}
if let peopleTextLayoutAndApply {
let peopleTextNode = peopleTextLayoutAndApply.1()
if strongSelf.peopleTextNode !== peopleTextNode {
strongSelf.peopleTextNode?.removeFromSupernode()
strongSelf.peopleTextNode = peopleTextNode
strongSelf.addSubnode(peopleTextNode)
}
peopleTextNode.frame = CGRect(origin: CGPoint(x: labelFrame.maxX + avatarsWidth, y: labelFrame.minY), size: peopleTextLayoutAndApply.0.size)
}
if let callIcon = callIcon {
if strongSelf.iconNode.image != callIcon {
strongSelf.iconNode.image = callIcon
}
strongSelf.iconNode.frame = CGRect(origin: CGPoint(x: titleFrame.minX + 1.0, y: labelFrame.minY + 4.0), size: callIcon.size)
}
if let buttonImage = buttonImage {
strongSelf.buttonNode.setImage(buttonImage, for: [])
strongSelf.buttonNode.frame = CGRect(origin: CGPoint(x: boundingWidth - buttonImage.size.width - 8.0, y: 15.0), size: buttonImage.size)
strongSelf.buttonNode.isHidden = !hasCallButton
}
if let activeConferenceUpdateTimer = strongSelf.activeConferenceUpdateTimer {
activeConferenceUpdateTimer.invalidate()
strongSelf.activeConferenceUpdateTimer = nil
}
if let updateConferenceTimerEndTimeout, updateConferenceTimerEndTimeout >= 0 {
strongSelf.activeConferenceUpdateTimer?.invalidate()
strongSelf.activeConferenceUpdateTimer = SwiftSignalKit.Timer(timeout: Double(updateConferenceTimerEndTimeout) + 0.5, repeat: false, completion: { [weak strongSelf] in
guard let strongSelf else {
return
}
strongSelf.requestInlineUpdate?()
}, queue: .mainQueue())
strongSelf.activeConferenceUpdateTimer?.start()
}
}
})
})
})
}
}
override public func animateInsertion(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override public func animateAdded(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
override public func animateRemoved(_ currentTimestamp: Double, duration: Double) {
self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
}
@objc private func callButtonPressed() {
if let item = self.item {
var isVideo = false
for media in item.message.media {
if let action = media as? TelegramMediaAction, case let .phoneCall(_, _, _, isVideoValue) = action.action {
isVideo = isVideoValue
} else if let action = media as? TelegramMediaAction, case .conferenceCall = action.action {
item.controllerInteraction.openConferenceCall(item.message)
return
}
}
item.controllerInteraction.callPeer(item.message.id.peerId, isVideo)
}
}
override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction {
if self.buttonNode.isHidden {
return ChatMessageBubbleContentTapAction(content: .none)
}
if self.buttonNode.frame.contains(point) {
return ChatMessageBubbleContentTapAction(content: .ignore)
} else if self.bounds.contains(point), let item = self.item {
var isVideo = false
for media in item.message.media {
if let action = media as? TelegramMediaAction, case let .phoneCall(_, _, _, isVideoValue) = action.action {
isVideo = isVideoValue
} else if let action = media as? TelegramMediaAction, case .conferenceCall = action.action {
return ChatMessageBubbleContentTapAction(content: .conferenceCall(message: item.message))
}
}
return ChatMessageBubbleContentTapAction(content: .call(peerId: item.message.id.peerId, isVideo: isVideo))
} else {
return ChatMessageBubbleContentTapAction(content: .none)
}
}
}