import Foundation import UIKit import AsyncDisplayKit import Display import SwiftSignalKit import Postbox import TelegramCore import TelegramUIPreferences import TelegramPresentationData import AccountContext import GridMessageSelectionNode import ChatControllerInteraction import ChatMessageDateAndStatusNode import ChatMessageBubbleContentNode import ChatMessageItemCommon import ChatMessageInteractiveMediaNode import ChatControllerInteraction import InvisibleInkDustNode public class ChatMessageMediaBubbleContentNode: ChatMessageBubbleContentNode { override public var supportsMosaic: Bool { return true } private let interactiveImageNode: ChatMessageInteractiveMediaNode private var selectionNode: GridMessageSelectionNode? private var highlightedState: Bool = false private var media: Media? private var mediaIndex: Int? private var automaticPlayback: Bool? override public var visibility: ListViewItemNodeVisibility { didSet { self.interactiveImageNode.visibility = self.visibility != .none } } required public init() { self.interactiveImageNode = ChatMessageInteractiveMediaNode() super.init() self.addSubnode(self.interactiveImageNode) self.interactiveImageNode.activateLocalContent = { [weak self] mode in guard let self, let item = self.item else { return } let openChatMessageMode: ChatControllerInteractionOpenMessageMode switch mode { case .default: openChatMessageMode = .default case .stream: openChatMessageMode = .stream case .automaticPlayback: openChatMessageMode = .automaticPlayback } let _ = item.controllerInteraction.openMessage(item.message, OpenMessageParams(mode: openChatMessageMode, mediaIndex: self.mediaIndex, progress: self.itemNode?.makeProgress())) } self.interactiveImageNode.activateAgeRestrictedMedia = { [weak self] in guard let self, let item = self.item else { return } let _ = item.controllerInteraction.openAgeRestrictedMessageMedia(item.message, { [weak self] in self?.interactiveImageNode.reveal() }) } self.interactiveImageNode.updateMessageReaction = { [weak self] message, value, force, sourceView in guard let strongSelf = self, let item = strongSelf.item else { return } item.controllerInteraction.updateMessageReaction(message, value, force, sourceView) } self.interactiveImageNode.activatePinch = { [weak self] sourceNode in guard let strongSelf = self, let _ = strongSelf.item else { return } strongSelf.item?.controllerInteraction.activateMessagePinch(sourceNode) } self.interactiveImageNode.playMessageEffect = { [weak self] message in guard let strongSelf = self, let _ = strongSelf.item else { return } strongSelf.item?.controllerInteraction.playMessageEffect(message) } } 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 interactiveImageLayout = self.interactiveImageNode.asyncLayout() return { item, layoutConstants, preparePosition, selection, constrainedSize, _ in var selectedMedia: Media? var selectedMediaIndex: Int? var extendedMedia: TelegramExtendedMedia? var automaticDownload: InteractiveMediaNodeAutodownloadMode = .none var automaticPlayback: Bool = false var contentMode: InteractiveMediaNodeContentMode = .aspectFit if let updatingMedia = item.attributes.updatingMedia, case let .update(mediaReference) = updatingMedia.media { selectedMedia = mediaReference.media } if selectedMedia == nil { for media in item.message.media { if let telegramImage = media as? TelegramMediaImage { selectedMedia = telegramImage if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramImage) { automaticDownload = .full } } else if let telegramStory = media as? TelegramMediaStory { selectedMedia = telegramStory if let storyMedia = item.message.associatedStories[telegramStory.storyId], case let .item(storyItem) = storyMedia.get(Stories.StoredItem.self), let media = storyItem.media { if let telegramImage = media as? TelegramMediaImage { if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramImage) { automaticDownload = .full } } else if let telegramFile = media as? TelegramMediaFile { if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramFile) { automaticDownload = .full } else if shouldPredownloadMedia(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, media: telegramFile) { automaticDownload = .prefetch } if !item.message.containsSecretMedia { if telegramFile.isAnimated && item.context.sharedContext.energyUsageSettings.autoplayGif { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } else if (telegramFile.isVideo && !telegramFile.isAnimated) && item.context.sharedContext.energyUsageSettings.autoplayVideo { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } } contentMode = .aspectFill } } } else if let telegramFile = media as? TelegramMediaFile { selectedMedia = telegramFile if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramFile) { automaticDownload = .full } else if shouldPredownloadMedia(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, media: telegramFile) { automaticDownload = .prefetch } if !item.message.containsSecretMedia { if telegramFile.isAnimated && item.context.sharedContext.energyUsageSettings.autoplayGif { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } else if (telegramFile.isVideo && !telegramFile.isAnimated) && item.context.sharedContext.energyUsageSettings.autoplayVideo { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } } contentMode = .aspectFill } else if let invoice = media as? TelegramMediaInvoice { selectedMedia = invoice extendedMedia = invoice.extendedMedia } else if let paidContent = media as? TelegramMediaPaidContent { selectedMedia = paidContent if case let .mosaic(_, _, index) = preparePosition, let index { extendedMedia = paidContent.extendedMedia[index] selectedMediaIndex = index } else { extendedMedia = paidContent.extendedMedia.first } } } } let _ = selectedMediaIndex if let extendedMedia, case let .full(media) = extendedMedia { if let telegramImage = media as? TelegramMediaImage { if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramImage) { automaticDownload = .full } } else if let telegramFile = media as? TelegramMediaFile { if shouldDownloadMediaAutomatically(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, authorPeerId: item.message.author?.id, contactsPeerIds: item.associatedData.contactsPeerIds, media: telegramFile) { automaticDownload = .full } else if shouldPredownloadMedia(settings: item.controllerInteraction.automaticMediaDownloadSettings, peerType: item.associatedData.automaticDownloadPeerType, networkType: item.associatedData.automaticDownloadNetworkType, media: telegramFile) { automaticDownload = .prefetch } if !item.message.containsSecretMedia { if telegramFile.isAnimated && item.context.sharedContext.energyUsageSettings.autoplayGif { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } else if (telegramFile.isVideo && !telegramFile.isAnimated) && item.context.sharedContext.energyUsageSettings.autoplayVideo { if case .full = automaticDownload { automaticPlayback = true } else { automaticPlayback = item.context.account.postbox.mediaBox.completedResourcePath(telegramFile.resource) != nil } } } contentMode = .aspectFill } } var hasReplyMarkup: Bool = false for attribute in item.message.attributes { if let attribute = attribute as? ReplyMarkupMessageAttribute, attribute.flags.contains(.inline), !attribute.rows.isEmpty { var isExtendedMedia = false for media in item.message.media { if let invoice = media as? TelegramMediaInvoice, let _ = invoice.extendedMedia { isExtendedMedia = true break } } if isExtendedMedia { var updatedRows: [ReplyMarkupRow] = [] for row in attribute.rows { let updatedButtons = row.buttons.filter { button in if case .payment = button.action { return false } else { return true } } if !updatedButtons.isEmpty { updatedRows.append(ReplyMarkupRow(buttons: updatedButtons)) } } if !updatedRows.isEmpty { hasReplyMarkup = true } } else { hasReplyMarkup = true } break } } let bubbleInsets: UIEdgeInsets let sizeCalculation: InteractiveMediaNodeSizeCalculation switch preparePosition { case .linear: if case .color = item.presentationData.theme.wallpaper { let colors: PresentationThemeBubbleColorComponents if item.message.effectivelyIncoming(item.context.account.peerId) { colors = item.presentationData.theme.theme.chat.message.incoming.bubble.withoutWallpaper } else { colors = item.presentationData.theme.theme.chat.message.outgoing.bubble.withoutWallpaper } if colors.fill[0] == colors.stroke || colors.stroke.alpha.isZero { bubbleInsets = UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0) } else { bubbleInsets = layoutConstants.bubble.strokeInsets } } else { bubbleInsets = layoutConstants.image.bubbleInsets } sizeCalculation = .constrained(CGSize(width: max(0.0, constrainedSize.width - bubbleInsets.left - bubbleInsets.right), height: constrainedSize.height)) case .mosaic: bubbleInsets = UIEdgeInsets() sizeCalculation = .unconstrained } var edited = false if item.attributes.updatingMedia != nil { edited = true } var viewCount: Int? var dateReplies = 0 var dateReactionsAndPeers = mergedMessageReactionsAndPeers(accountPeerId: item.context.account.peerId, accountPeer: item.associatedData.accountPeer, message: item.message) if item.message.isRestricted(platform: "ios", contentSettings: item.context.currentContentSettings.with { $0 }) { dateReactionsAndPeers = ([], []) } for attribute in item.message.attributes { if let attribute = attribute as? EditedMessageAttribute { if case .mosaic = preparePosition { } else { edited = !attribute.isHidden } } else if let attribute = attribute as? ViewCountMessageAttribute { viewCount = attribute.count } else if let attribute = attribute as? ReplyThreadMessageAttribute, case .peer = item.chatLocation { if let channel = item.message.peers[item.message.id.peerId] as? TelegramChannel, case .group = channel.info { dateReplies = Int(attribute.count) } } } let dateFormat: MessageTimestampStatusFormat if item.presentationData.isPreview { dateFormat = .full } else { dateFormat = .regular } let dateText = stringForMessageTimestampStatus(accountPeerId: item.context.account.peerId, message: item.message, dateTimeFormat: item.presentationData.dateTimeFormat, nameDisplayOrder: item.presentationData.nameDisplayOrder, strings: item.presentationData.strings, format: dateFormat, associatedData: item.associatedData) let statusType: ChatMessageDateAndStatusType? if case .customChatContents = item.associatedData.subject { statusType = nil } else if item.message.timestamp == 0 { statusType = nil } else { switch preparePosition { case .linear(_, .None), .linear(_, .Neighbour(true, _, _)): if item.message.effectivelyIncoming(item.context.account.peerId) { statusType = .ImageIncoming } else { if item.message.flags.contains(.Failed) { statusType = .ImageOutgoing(.Failed) } else if (item.message.flags.isSending && !item.message.isSentOrAcknowledged) || item.attributes.updatingMedia != nil { statusType = .ImageOutgoing(.Sending) } else { statusType = .ImageOutgoing(.Sent(read: item.read)) } } case .mosaic: statusType = nil default: statusType = nil } } var isReplyThread = false if case .replyThread = item.chatLocation { isReplyThread = true } let dateAndStatus = statusType.flatMap { statusType -> ChatMessageDateAndStatus in ChatMessageDateAndStatus( type: statusType, edited: edited, viewCount: viewCount, dateReactions: dateReactionsAndPeers.reactions, dateReactionPeers: dateReactionsAndPeers.peers, dateReplies: dateReplies, isPinned: item.message.tags.contains(.pinned) && !item.associatedData.isInPinnedListMode && !isReplyThread, dateText: dateText ) } let (unboundSize, initialWidth, refineLayout) = interactiveImageLayout(item.context, item.presentationData, item.presentationData.dateTimeFormat, item.message, item.associatedData, item.attributes, selectedMedia!, selectedMediaIndex, dateAndStatus, automaticDownload, item.associatedData.automaticDownloadPeerType, item.associatedData.automaticDownloadPeerId, sizeCalculation, layoutConstants, contentMode, item.controllerInteraction.presentationContext) let forceFullCorners = false let contentProperties = ChatMessageBubbleContentProperties(hidesSimpleAuthorHeader: true, headerSpacing: 7.0, hidesBackground: .emptyWallpaper, forceFullCorners: forceFullCorners, forceAlignment: .none) return (contentProperties, unboundSize, initialWidth + bubbleInsets.left + bubbleInsets.right, { constrainedSize, position in var wideLayout = true if case let .mosaic(_, wide) = position { wideLayout = wide automaticPlayback = automaticPlayback && wide } var updatedPosition: ChatMessageBubbleContentPosition = position if forceFullCorners, case .linear = updatedPosition { updatedPosition = .linear(top: .None(.None(.None)), bottom: .None(.None(.None))) } else if hasReplyMarkup, case let .linear(top, _) = updatedPosition { updatedPosition = .linear(top: top, bottom: .BubbleNeighbour) } let imageCorners = chatMessageBubbleImageContentCorners(relativeContentPosition: updatedPosition, normalRadius: layoutConstants.image.defaultCornerRadius, mergedRadius: layoutConstants.image.mergedCornerRadius, mergedWithAnotherContentRadius: layoutConstants.image.contentMergedCornerRadius, layoutConstants: layoutConstants, chatPresentationData: item.presentationData) let (refinedWidth, finishLayout) = refineLayout(CGSize(width: constrainedSize.width - bubbleInsets.left - bubbleInsets.right, height: constrainedSize.height), automaticPlayback, wideLayout, imageCorners) return (refinedWidth + bubbleInsets.left + bubbleInsets.right, { boundingWidth in let (imageSize, imageApply) = finishLayout(boundingWidth - bubbleInsets.left - bubbleInsets.right) let imageLayoutSize = CGSize(width: imageSize.width + bubbleInsets.left + bubbleInsets.right, height: imageSize.height + bubbleInsets.top + bubbleInsets.bottom) let layoutWidth = imageLayoutSize.width let layoutSize = CGSize(width: layoutWidth, height: imageLayoutSize.height) return (layoutSize, { [weak self] animation, synchronousLoads, _ in if let strongSelf = self { strongSelf.item = item strongSelf.media = selectedMedia strongSelf.mediaIndex = selectedMediaIndex strongSelf.automaticPlayback = automaticPlayback let imageFrame = CGRect(origin: CGPoint(x: bubbleInsets.left, y: bubbleInsets.top), size: imageSize) animation.animator.updateFrame(layer: strongSelf.interactiveImageNode.layer, frame: imageFrame, completion: nil) imageApply(animation, synchronousLoads) if let selection = selection { if let selectionNode = strongSelf.selectionNode { selectionNode.frame = imageFrame selectionNode.updateSelected(selection, animated: animation.isAnimated) } else { let selectionNode = GridMessageSelectionNode(theme: item.presentationData.theme.theme, toggle: { value in item.controllerInteraction.toggleMessagesSelection([item.message.id], value) }) strongSelf.selectionNode = selectionNode strongSelf.addSubnode(selectionNode) selectionNode.frame = imageFrame selectionNode.updateSelected(selection, animated: false) if animation.isAnimated { selectionNode.animateIn() } } } else if let selectionNode = strongSelf.selectionNode { strongSelf.selectionNode = nil if animation.isAnimated { selectionNode.animateOut(completion: { [weak selectionNode] in selectionNode?.removeFromSupernode() }) } else { selectionNode.removeFromSupernode() } } if let forwardInfo = item.message.forwardInfo, forwardInfo.flags.contains(.isImported) { strongSelf.interactiveImageNode.dateAndStatusNode.pressed = { guard let strongSelf = self else { return } item.controllerInteraction.displayImportedMessageTooltip(strongSelf.interactiveImageNode.dateAndStatusNode) } } } }) }) }) } } override public func transitionNode(messageId: MessageId, media: Media, adjustRect: Bool) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if self.item?.message.id == messageId, var currentMedia = self.media { if let invoice = currentMedia as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { currentMedia = fullMedia } if let paidContent = currentMedia as? TelegramMediaPaidContent, case let .full(fullMedia) = paidContent.extendedMedia[self.mediaIndex ?? 0] { currentMedia = fullMedia } if currentMedia.isSemanticallyEqual(to: media) { return self.interactiveImageNode.transitionNode(adjustRect: adjustRect) } } return nil } override public func updateHiddenMedia(_ media: [Media]?) -> Bool { var mediaHidden = false var currentMedia = self.media if let invoice = currentMedia as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { currentMedia = fullMedia } if let paidContent = currentMedia as? TelegramMediaPaidContent, case let .full(fullMedia) = paidContent.extendedMedia[self.mediaIndex ?? 0] { currentMedia = fullMedia } if let currentMedia = currentMedia, let media = media { for item in media { if item.isSemanticallyEqual(to: currentMedia) { mediaHidden = true break } } } self.interactiveImageNode.isHidden = mediaHidden self.interactiveImageNode.updateIsHidden(mediaHidden) /*if let automaticPlayback = self.automaticPlayback { if !automaticPlayback { self.dateAndStatusNode.isHidden = false } else if self.dateAndStatusNode.isHidden != mediaHidden { if mediaHidden { self.dateAndStatusNode.isHidden = true } else { self.dateAndStatusNode.isHidden = false self.dateAndStatusNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } } }*/ return mediaHidden } override public func playMediaWithSound() -> ((Double?) -> Void, Bool, Bool, Bool, ASDisplayNode?)? { return self.interactiveImageNode.playMediaWithSound() } override public func tapActionAtPoint(_ point: CGPoint, gesture: TapLongTapOrDoubleTapGesture, isEstimating: Bool) -> ChatMessageBubbleContentTapAction { if self.interactiveImageNode.ignoreTapActionAtPoint(point) { return ChatMessageBubbleContentTapAction(content: .ignore) } return ChatMessageBubbleContentTapAction(content: .none) } override public func animateInsertion(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override public func animateAdded(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override public func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false) } override public func animateInsertionIntoBubble(_ duration: Double) { self.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) } override public func updateHighlightedState(animated: Bool) -> Bool { guard let item = self.item else { return false } let highlighted = item.controllerInteraction.highlightedState?.messageStableId == item.message.stableId if self.highlightedState != highlighted { self.highlightedState = highlighted if highlighted { self.interactiveImageNode.setOverlayColor(item.presentationData.theme.theme.chat.message.mediaHighlightOverlayColor, animated: false) } else { self.interactiveImageNode.setOverlayColor(nil, animated: animated) } } return false } override public func reactionTargetView(value: MessageReaction.Reaction) -> UIView? { if !self.interactiveImageNode.dateAndStatusNode.isHidden { return self.interactiveImageNode.dateAndStatusNode.reactionView(value: value) } return nil } override public func messageEffectTargetView() -> UIView? { if !self.interactiveImageNode.dateAndStatusNode.isHidden { return self.interactiveImageNode.dateAndStatusNode.messageEffectTargetView() } return nil } }