import Foundation import UIKit import AsyncDisplayKit import Display import TelegramCore 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: [EnginePeer] = [] 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(EnginePeer(peer)) } for id in conferenceCall.otherParticipants { if let peer = item.message.peers[id] { peopleAvatars.append(EnginePeer(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 conferenceCall.duration == nil && item.message.timestamp < currentTime - missedTimeout { titleString = item.presentationData.strings.Chat_CallMessage_MissedGroupCall } 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: EngineMessage(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: Array(peopleAvatars.prefix(3)), 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) } } }