import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import TelegramUIPreferences import ComponentFlow import AudioTranscriptionButtonComponent import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ChatMessageItemCommon import ChatMessageInteractiveInstantVideoNode import ChatMessageInteractiveFileNode import ChatControllerInteraction extension ChatMessageInteractiveInstantVideoNode.AnimateFileNodeDescription { convenience init(_ node: ChatMessageInteractiveFileNode) { self.init( node: node, textClippingNode: node.textClippingNode, dateAndStatusNode: node.dateAndStatusNode, fetchingTextNode: node.fetchingTextNode, waveformView: node.waveformView, statusNode: node.statusNode, audioTranscriptionButton: node.audioTranscriptionButton ) } } public class ChatMessageInstantVideoBubbleContentNode: ChatMessageBubbleContentNode { public let interactiveFileNode: ChatMessageInteractiveFileNode public let interactiveVideoNode: ChatMessageInteractiveInstantVideoNode private let maskLayer = SimpleLayer() private let maskForeground = SimpleLayer() private let backdropMaskLayer = SimpleLayer() private let backdropMaskForeground = BubbleMaskLayer() private var isExpanded = false private var audioTranscriptionState: AudioTranscriptionButtonComponent.TranscriptionState = .collapsed public var hasExpandedAudioTranscription: Bool { if case .expanded = self.audioTranscriptionState { return true } else { return false } } override public var visibility: ListViewItemNodeVisibility { didSet { var wasVisible = false if case .visible = oldValue { wasVisible = true } let isVisible = self.isContentVisible if wasVisible != isVisible { if !isVisible { Queue.mainQueue().after(0.05) { if isVisible == self.isContentVisible { self.interactiveVideoNode.visibility = isVisible } } } else { self.interactiveVideoNode.visibility = isVisible } } } } private var isContentVisible: Bool { var isVisible = false if case .visible = self.visibility { isVisible = true } return isVisible } required public init() { self.interactiveFileNode = ChatMessageInteractiveFileNode() self.interactiveVideoNode = ChatMessageInteractiveInstantVideoNode() super.init() self.maskForeground.backgroundColor = UIColor.white.cgColor self.maskForeground.masksToBounds = true self.maskLayer.addSublayer(self.maskForeground) self.addSubnode(self.interactiveVideoNode) self.interactiveVideoNode.requestUpdateLayout = { [weak self] _ in if let strongSelf = self, let item = strongSelf.item { let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) } } self.interactiveVideoNode.updateTranscriptionExpanded = { [weak self] state in if let strongSelf = self, let item = strongSelf.item { let previous = strongSelf.audioTranscriptionState strongSelf.audioTranscriptionState = state strongSelf.interactiveFileNode.audioTranscriptionState = state let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, state != .inProgress && previous != state) } } self.interactiveVideoNode.updateTranscriptionText = { [weak self] text in if let strongSelf = self, let item = strongSelf.item { strongSelf.interactiveFileNode.forcedAudioTranscriptionText = text let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) } } self.interactiveFileNode.updateTranscriptionExpanded = { [weak self] state in if let strongSelf = self, let item = strongSelf.item { let previous = strongSelf.audioTranscriptionState strongSelf.audioTranscriptionState = state strongSelf.interactiveVideoNode.audioTranscriptionState = state let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, previous != state) } } self.interactiveFileNode.toggleSelection = { [weak self] value in if let strongSelf = self, let item = strongSelf.item { item.controllerInteraction.toggleMessagesSelection([item.message.id], value) } } self.interactiveFileNode.activateLocalContent = { [weak self] in if let strongSelf = self, let item = strongSelf.item { let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: .default)) } } self.interactiveFileNode.requestUpdateLayout = { [weak self] _ in if let strongSelf = self, let item = strongSelf.item { let _ = item.controllerInteraction.requestMessageUpdate(item.message.id, false) } } self.interactiveFileNode.displayImportedTooltip = { [weak self] sourceNode in if let strongSelf = self, let item = strongSelf.item { let _ = item.controllerInteraction.displayImportedMessageTooltip(sourceNode) } } self.interactiveFileNode.dateAndStatusNode.reactionSelected = { [weak self] _, value, sourceView in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.updateMessageReaction(item.topMessage, .reaction(value), false, sourceView) } self.interactiveFileNode.dateAndStatusNode.openReactionPreview = { [weak self] gesture, sourceNode, value in guard let strongSelf = self, let item = strongSelf.item else { gesture?.cancel() return } item.controllerInteraction.openMessageReactionContextMenu(item.topMessage, sourceNode, gesture, value) } self.interactiveFileNode.updateIsTextSelectionActive = { [weak self] value in self?.updateIsTextSelectionActive?(value) } } override public func accessibilityActivate() -> Bool { if let item = self.item { let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: .default)) } return 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 interactiveVideoLayout = self.interactiveVideoNode.asyncLayout() let interactiveFileLayout = self.interactiveFileNode.asyncLayout() let currentExpanded = self.isExpanded let audioTranscriptionState = self.audioTranscriptionState let didSetupFileNode = self.item != nil return { item, layoutConstants, preparePosition, selection, constrainedSize, avatarInset in var selectedFile: TelegramMediaFile? for media in item.message.media { if let telegramFile = media as? TelegramMediaFile { selectedFile = telegramFile } } let isViewOnceMessage = item.message.minAutoremoveOrClearTimeout == viewOnceTimeout let forceIsPlaying = isViewOnceMessage && didSetupFileNode var incoming = item.message.effectivelyIncoming(item.context.account.peerId) if let subject = item.associatedData.subject, case let .messageOptions(_, _, info) = subject, case .forward = info { incoming = false } let statusType: ChatMessageDateAndStatusType? if case .customChatContents = item.associatedData.subject { statusType = nil } else { switch preparePosition { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if incoming { statusType = .BubbleIncoming } else { if item.message.flags.contains(.Failed) { statusType = .BubbleOutgoing(.Failed) } else if (item.message.flags.isSending && !item.message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { statusType = .BubbleOutgoing(.Sending) } else { statusType = .BubbleOutgoing(.Sent(read: item.read)) } } default: statusType = nil } } let automaticDownload = shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: selectedFile!) let (initialWidth, refineLayout) = interactiveFileLayout(ChatMessageInteractiveFileNode.Arguments( context: item.context, presentationData: item.presentationData, customTintColor: nil, message: item.message, topMessage: item.topMessage, associatedData: item.associatedData, chatLocation: item.chatLocation, attributes: item.attributes, isPinned: item.isItemPinned, forcedIsEdited: item.isItemEdited, file: selectedFile!, automaticDownload: automaticDownload, incoming: incoming, isRecentActions: item.associatedData.isRecentActions, forcedResourceStatus: item.associatedData.forcedResourceStatus, dateAndStatusType: statusType, displayReactions: false, messageSelection: item.message.groupingKey != nil ? selection : nil, isAttachedContentBlock: false, layoutConstants: layoutConstants, constrainedSize: CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right, height: constrainedSize.height), controllerInteraction: item.controllerInteraction )) var isReplyThread = false if case .replyThread = item.chatLocation { isReplyThread = true } var isExpanded = false if case .expanded = audioTranscriptionState { isExpanded = true } var isPlaying = false let normalDisplaySize = layoutConstants.instantVideo.dimensions var displaySize = normalDisplaySize let maximumDisplaySize = CGSize(width: min(404, constrainedSize.width - 2.0), height: min(404, constrainedSize.width - 2.0)) if (item.associatedData.currentlyPlayingMessageId == item.message.index || forceIsPlaying) && (!isViewOnceMessage || item.associatedData.isStandalone) { isPlaying = true if !isExpanded { displaySize = maximumDisplaySize } } let leftInset: CGFloat = 0.0 let rightInset: CGFloat = 0.0 let (videoLayout, videoApply) = interactiveVideoLayout(ChatMessageBubbleContentItem(context: item.context, controllerInteraction: item.controllerInteraction, message: item.message, topMessage: item.message, read: item.read, chatLocation: item.chatLocation, presentationData: item.presentationData, associatedData: item.associatedData, attributes: item.attributes, isItemPinned: item.message.tags.contains(.pinned) && !isReplyThread, isItemEdited: false), constrainedSize.width - leftInset - rightInset - avatarInset, displaySize, maximumDisplaySize, isPlaying ? 1.0 : 0.0, .free, automaticDownload, avatarInset) let videoFrame = CGRect(origin: CGPoint(x: 1.0, y: 1.0), size: videoLayout.contentSize) let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: false, headerSpacing: 0.0, hidesBackground: .never, forceFullCorners: false, forceAlignment: .none, shareButtonOffset: isExpanded ? nil : CGPoint(x: avatarInset + displaySize.width + 4.0, y: -25.0), hidesHeaders: !isExpanded, avatarOffset: !isExpanded && isPlaying ? -100.0 : 0.0) let videoFrameWidth = videoFrame.width + 2.0 return (contentProperties, nil, initialWidth, { constrainedSize, position in var refinedWidth = videoFrameWidth var finishLayout: ((CGFloat) -> (CGSize, (Bool, ListViewItemUpdateAnimation, ListViewItemApply?) -> Void))? if isExpanded || !didSetupFileNode { (refinedWidth, finishLayout) = refineLayout(CGSize(width: constrainedSize.width - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right - 44.0, height: constrainedSize.height)) refinedWidth += layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right } if !isExpanded { refinedWidth = videoFrameWidth } return (refinedWidth, { boundingWidth in var finalSize: CGSize var finalFileSize: CGSize? var finalFileApply: ((Bool, ListViewItemUpdateAnimation, ListViewItemApply?) -> Void)? if let finishLayout = finishLayout { let (fileSize, fileApply) = finishLayout(boundingWidth - layoutConstants.file.bubbleInsets.left - layoutConstants.file.bubbleInsets.right) if isExpanded { finalSize = CGSize(width: fileSize.width + layoutConstants.file.bubbleInsets.left + layoutConstants.file.bubbleInsets.right, height: fileSize.height + layoutConstants.file.bubbleInsets.top + layoutConstants.file.bubbleInsets.bottom) } else { finalSize = CGSize(width: boundingWidth, height: videoFrame.height + 2.0) } finalFileSize = fileSize finalFileApply = fileApply } else { finalSize = CGSize(width: boundingWidth, height: videoFrame.height + 2.0) } return (finalSize, { [weak self] animation, synchronousLoads, applyInfo in if let strongSelf = self { strongSelf.item = item strongSelf.isExpanded = isExpanded strongSelf.bubbleBackgroundNode?.layer.mask = strongSelf.maskLayer if let bubbleBackdropNode = strongSelf.bubbleBackdropNode, bubbleBackdropNode.hasImage && strongSelf.backdropMaskForeground.superlayer == nil { strongSelf.bubbleBackdropNode?.overrideMask = true strongSelf.bubbleBackdropNode?.maskView?.layer.addSublayer(strongSelf.backdropMaskForeground) } strongSelf.maskLayer.frame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: 640.0, height: 640.0)) strongSelf.backdropMaskLayer.frame = strongSelf.maskLayer.frame let bubbleSize = strongSelf.bubbleBackgroundNode?.backgroundFrame.size ?? finalSize let radius: CGFloat = displaySize.width / 2.0 let maskCornerRadius = isExpanded ? 1.0 : radius let maskFrame = CGRect(origin: CGPoint(x: isExpanded ? 1.0 : (incoming ? 7.0 : 1.0), y: isExpanded ? 0.0 : 1.0), size: isExpanded ? bubbleSize : CGSize(width: radius * 2.0, height: radius * 2.0)) animation.animator.updateCornerRadius(layer: strongSelf.maskForeground, cornerRadius: maskCornerRadius, completion: nil) animation.animator.updateFrame(layer: strongSelf.maskForeground, frame: maskFrame, completion: nil) let backdropMaskFrame = CGRect(origin: CGPoint(x: isExpanded ? (incoming ? 8.0 : 2.0) : (incoming ? 8.0 : 2.0), y: isExpanded ? 2.0 : 2.0), size: isExpanded ? CGSize(width: bubbleSize.width - 8.0, height: bubbleSize.height - 3.0) : CGSize(width: radius * 2.0, height: radius * 2.0)) let topLeftCornerRadius: CGFloat let topRightCornerRadius: CGFloat let bottomLeftCornerRadius: CGFloat let bottomRightCornerRadius: CGFloat if let bubbleCorners = strongSelf.bubbleBackgroundNode?.currentCorners(bubbleCorners: item.presentationData.chatBubbleCorners) { topLeftCornerRadius = isExpanded ? bubbleCorners.topLeftRadius : radius topRightCornerRadius = isExpanded ? bubbleCorners.topRightRadius : radius bottomLeftCornerRadius = isExpanded ? bubbleCorners.bottomLeftRadius : radius bottomRightCornerRadius = isExpanded ? bubbleCorners.bottomRightRadius : radius } else { let backdropRadius = isExpanded ? item.presentationData.chatBubbleCorners.mainRadius : radius topLeftCornerRadius = backdropRadius topRightCornerRadius = backdropRadius bottomLeftCornerRadius = backdropRadius bottomRightCornerRadius = backdropRadius } strongSelf.backdropMaskForeground.update( size: backdropMaskFrame.size, topLeftCornerRadius: topLeftCornerRadius, topRightCornerRadius: topRightCornerRadius, bottomLeftCornerRadius: bottomLeftCornerRadius, bottomRightCornerRadius: bottomRightCornerRadius, animator: animation.animator ) animation.animator.updateFrame(layer: strongSelf.backdropMaskForeground, frame: backdropMaskFrame, completion: nil) let videoLayoutData: ChatMessageInstantVideoItemLayoutData = .constrained(left: 0.0, right: 0.0) var videoAnimation = animation var fileAnimation = animation if currentExpanded != isExpanded { videoAnimation = .None fileAnimation = .None } animation.animator.updateFrame(layer: strongSelf.interactiveVideoNode.layer, frame: videoFrame, completion: nil) videoApply(videoLayoutData, videoAnimation) if let fileSize = finalFileSize { strongSelf.interactiveFileNode.frame = CGRect(origin: CGPoint(x: layoutConstants.file.bubbleInsets.left, y: layoutConstants.file.bubbleInsets.top), size: fileSize) finalFileApply?(synchronousLoads, fileAnimation, applyInfo) } if currentExpanded != isExpanded { if isExpanded { strongSelf.interactiveVideoNode.animateTo(ChatMessageInteractiveInstantVideoNode.AnimateFileNodeDescription(strongSelf.interactiveFileNode), animator: animation.animator) } else { strongSelf.interactiveVideoNode.animateFrom(ChatMessageInteractiveInstantVideoNode.AnimateFileNodeDescription(strongSelf.interactiveFileNode), animator: animation.animator) } } } }) }) }) } } override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { return nil } override public func updateHiddenMedia(_ media: [Media]?) -> Bool { return false } override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { self.interactiveVideoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateAdded(_ currentTimestamp: Double, duration: Double) { self.interactiveVideoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.interactiveVideoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) } override public func willUpdateIsExtractedToContextPreview(_ value: Bool) { self.interactiveFileNode.willUpdateIsExtractedToContextPreview(value) } override public func updateIsExtractedToContextPreview(_ value: Bool) { self.interactiveFileNode.updateIsExtractedToContextPreview(value) } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { if !self.interactiveFileNode.isHidden { if self.interactiveFileNode.dateAndStatusNode.supernode != nil, let _ = self.interactiveFileNode.dateAndStatusNode.hitTest(self.view.convert(point, to: self.interactiveFileNode.dateAndStatusNode.view), with: nil) { return ChatMessageBubbleContentTapAction(content: .ignore) } if self.interactiveFileNode.hasTapAction(at: self.view.convert(point, to: self.interactiveFileNode.view)) { return ChatMessageBubbleContentTapAction(content: .ignore) } } if !self.interactiveVideoNode.isHidden { if self.interactiveVideoNode.dateAndStatusNode.supernode != nil, let _ = self.interactiveVideoNode.dateAndStatusNode.hitTest(self.view.convert(point, to: self.interactiveVideoNode.dateAndStatusNode.view), with: nil) { return ChatMessageBubbleContentTapAction(content: .ignore) } if let audioTranscriptionButton = self.interactiveVideoNode.audioTranscriptionButton, let _ = audioTranscriptionButton.hitTest(self.view.convert(point, to: audioTranscriptionButton), with: nil) { return ChatMessageBubbleContentTapAction(content: .ignore) } } return super.tapActionAtPoint(point, gesture: gesture, isEstimating: isEstimating) } override public func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if self.isExpanded, let result = self.interactiveFileNode.hitTest(self.view.convert(point, to: self.interactiveFileNode.view), with: event) { return result } if !self.isExpanded, let result = self.interactiveVideoNode.hitTest(self.view.convert(point, to: self.interactiveVideoNode.view), with: event) { return result } return super.hitTest(point, with: event) } override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { if !self.interactiveVideoNode.dateAndStatusNode.isHidden { return self.interactiveVideoNode.dateAndStatusNode.reactionView(value: value) } return nil } override public func targetForStoryTransition(id: StoryId) -> UIView? { return self.interactiveVideoNode.targetForStoryTransition(id: id) } override public var disablesClipping: Bool { return true } } private class BubbleMaskLayer: SimpleLayer { private class CornerLayer: SimpleLayer { private let contentLayer = SimpleLayer() override init(layer: Any) { super.init(layer: layer) } init(cornerMask: CACornerMask) { super.init() self.masksToBounds = true self.contentLayer.backgroundColor = UIColor.white.cgColor self.contentLayer.masksToBounds = true self.contentLayer.maskedCorners = cornerMask self.addSublayer(self.contentLayer) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(size: CGSize, cornerRadius: CGFloat, animator: ControlledTransitionAnimator) { animator.updateCornerRadius(layer: self.contentLayer, cornerRadius: cornerRadius, completion: nil) let mask = self.contentLayer.maskedCorners var origin = CGPoint() if mask == .layerMinXMinYCorner { origin = .zero } else if mask == .layerMaxXMinYCorner { origin = CGPoint(x: -size.width / 2.0, y: 0.0) } else if mask == .layerMinXMaxYCorner { origin = CGPoint(x: 0.0, y: -size.height / 2.0) } else if mask == .layerMaxXMaxYCorner { origin = CGPoint(x: -size.width / 2.0, y: -size.height / 2.0) } animator.updateFrame(layer: self.contentLayer, frame: CGRect(origin: origin, size: size), completion: nil) } } private let topLeft = CornerLayer(cornerMask: [.layerMinXMinYCorner]) private let topRight = CornerLayer(cornerMask: [.layerMaxXMinYCorner]) private let bottomLeft = CornerLayer(cornerMask: [.layerMinXMaxYCorner]) private let bottomRight = CornerLayer(cornerMask: [.layerMaxXMaxYCorner]) override init(layer: Any) { super.init(layer: layer) } override init() { super.init() self.addSublayer(self.topLeft) self.addSublayer(self.topRight) self.addSublayer(self.bottomLeft) self.addSublayer(self.bottomRight) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update( size: CGSize, topLeftCornerRadius: CGFloat, topRightCornerRadius: CGFloat, bottomLeftCornerRadius: CGFloat, bottomRightCornerRadius: CGFloat, animator: ControlledTransitionAnimator ) { var size = CGSize(width: floor(size.width), height: floor(size.height)) if Int(size.width) % 2 != 0 { size.width += 1.0 } if Int(size.height) % 2 != 0 { size.height += 1.0 } animator.updateFrame(layer: self.topLeft, frame: CGRect(origin: .zero, size: CGSize(width: size.width / 2.0, height: size.height / 2.0)), completion: nil) animator.updateFrame(layer: self.topRight, frame: CGRect(origin: CGPoint(x: size.width / 2.0, y: 0.0), size: CGSize(width: size.width / 2.0, height: size.height / 2.0)), completion: nil) animator.updateFrame(layer: self.bottomLeft, frame: CGRect(origin: CGPoint(x: 0.0, y: size.height / 2.0), size: CGSize(width: size.width / 2.0, height: size.height / 2.0)), completion: nil) animator.updateFrame(layer: self.bottomRight, frame: CGRect(origin: CGPoint(x: size.width / 2.0, y: size.height / 2.0), size: CGSize(width: size.width / 2.0, height: size.height / 2.0)), completion: nil) self.topLeft.update(size: size, cornerRadius: topLeftCornerRadius, animator: animator) self.topRight.update(size: size, cornerRadius: topRightCornerRadius, animator: animator) self.bottomLeft.update(size: size, cornerRadius: bottomLeftCornerRadius, animator: animator) self.bottomRight.update(size: size, cornerRadius: bottomRightCornerRadius, animator: animator) } }