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 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 { peopleTextString = item.presentationData.strings.Chat_CallMessage_GroupCallParticipantCount(Int32(conferenceCall.otherParticipants.count + 1)) if let peer = item.message.author { peopleAvatars.append(peer) } for id in conferenceCall.otherParticipants { if let peer = item.message.peers[id] { peopleAvatars.append(peer) } } } let missedTimeout: Int32 #if DEBUG && false missedTimeout = 5 #else missedTimeout = 30 #endif let currentTime = Int32(Date().timeIntervalSince1970) if conferenceCall.flags.contains(.isMissed) { titleString = item.presentationData.strings.Chat_CallMessage_DeclinedGroupCall } else if item.message.timestamp < currentTime - missedTimeout { titleString = item.presentationData.strings.Chat_CallMessage_MissedGroupCall } else if conferenceCall.duration != nil { titleString = item.presentationData.strings.Chat_CallMessage_CancelledGroupCall } else { if incoming { titleString = item.presentationData.strings.Chat_CallMessage_IncomingGroupCall } else { titleString = item.presentationData.strings.Chat_CallMessage_OutgoingGroupCall } 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(min(3, 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 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.prefix(3).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) } 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.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) } } }