import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SwiftSignalKit import TelegramPresentationData import AccountContext import AppBundle import ChatPresentationInterfaceState import ChatInputPanelNode import ReactionSelectionNode import EntityKeyboard import TopMessageReactions import GlassBackgroundComponent import ComponentFlow import ComponentDisplayAdapters private final class ChatMessageSelectionInputPanelNodeViewForOverlayContent: UIView, ChatInputPanelViewForOverlayContent { var reactionContextNode: ReactionContextNode? var anchorRect: CGRect? override init(frame: CGRect) { super.init(frame: frame) self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.backgroundTapGesture(_:)))) } required init(coder: NSCoder) { preconditionFailure() } @objc private func backgroundTapGesture(_ recognizer: UITapGestureRecognizer) { if case .ended = recognizer.state { self.dismissReactionSelection() } } func dismissReactionSelection() { if let reactionContextNode = self.reactionContextNode { self.reactionContextNode = nil reactionContextNode.animateOut(to: self.anchorRect, animatingOutToReaction: false) ContainedViewLayoutTransition.animated(duration: 0.25, curve: .easeInOut).updateAlpha(node: reactionContextNode, alpha: 0.0, completion: { [weak reactionContextNode] _ in reactionContextNode?.removeFromSupernode() }) } } func maybeDismissContent(point: CGPoint) { if self.hitTest(point, with: nil) == self { self.dismissReactionSelection() } } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let reactionContextNode = self.reactionContextNode { if let result = reactionContextNode.view.hitTest(self.convert(point, to: reactionContextNode.view), with: event) { return result } return self } return nil } } private final class GlassButtonView: HighlightTrackingButton { private struct Params: Equatable { let theme: PresentationTheme let size: CGSize init(theme: PresentationTheme, size: CGSize) { self.theme = theme self.size = size } static func ==(lhs: Params, rhs: Params) -> Bool { if lhs.theme !== rhs.theme { return false } if lhs.size != rhs.size { return false } return true } } private let backgroundView: GlassBackgroundView private let iconView: GlassBackgroundView.ContentImageView private var params: Params? var isImplicitlyDisabled: Bool = false { didSet { self.updateIsEnabled() } } override var isEnabled: Bool { didSet { self.updateIsEnabled() } } var icon: String? { didSet { if self.icon == oldValue { return } if let icon = self.icon { self.iconView.image = UIImage(bundleImageName: icon)?.withRenderingMode(.alwaysTemplate) } else { self.iconView.image = nil } if let params = self.params { self.updateImpl(params: params, transition: .immediate) } } } override init(frame: CGRect) { self.backgroundView = GlassBackgroundView() self.backgroundView.isUserInteractionEnabled = false self.iconView = GlassBackgroundView.ContentImageView() self.backgroundView.contentView.addSubview(self.iconView) super.init(frame: frame) self.addSubview(self.backgroundView) self.highligthedChanged = { [weak self] highlighted in guard let self else { return } if highlighted && self.isEnabled && !self.isImplicitlyDisabled { self.backgroundView.contentView.alpha = 0.6 } else { self.backgroundView.contentView.alpha = 1.0 self.backgroundView.contentView.layer.animateAlpha(from: 0.6, to: 1.0, duration: 0.2) } } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update(theme: PresentationTheme, size: CGSize, transition: ComponentTransition) { let params = Params(theme: theme, size: size) if self.params != params { self.iconView.tintColor = params.theme.chat.inputPanel.inputControlColor self.params = params self.updateImpl(params: params, transition: transition) } } private func updateImpl(params: Params, transition: ComponentTransition) { if let image = self.iconView.image { let iconFrame = image.size.centered(in: CGRect(origin: CGPoint(), size: params.size)) transition.setFrame(view: self.iconView, frame: iconFrame) } transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(), size: params.size)) self.backgroundView.update(size: params.size, cornerRadius: min(params.size.width, params.size.height) * 0.5, isDark: params.theme.overallDarkAppearance, tintColor: .init(kind: .panel, color: params.theme.chat.inputPanel.inputBackgroundColor.withMultipliedAlpha(0.7)), transition: transition) let isEnabled = self.isEnabled && !self.isImplicitlyDisabled self.iconView.alpha = isEnabled ? 1.0 : 0.5 self.iconView.tintMask.alpha = self.iconView.alpha } private func updateIsEnabled() { if let params = self.params { self.updateImpl(params: params, transition: .immediate) } } } public final class ChatMessageSelectionInputPanelNode: ChatInputPanelNode { private let deleteButton: GlassButtonView private let reportButton: GlassButtonView private let forwardButton: GlassButtonView private let shareButton: GlassButtonView private let tagButton: GlassButtonView private let tagEditButton: GlassButtonView private let reactionOverlayContainer: ChatMessageSelectionInputPanelNodeViewForOverlayContent private var validLayout: (width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, metrics: LayoutMetrics, isSecondary: Bool, isMediaInputExpanded: Bool)? private var presentationInterfaceState: ChatPresentationInterfaceState? private var actions: ChatAvailableMessageActions? private var theme: PresentationTheme private let peerMedia: Bool private let canDeleteMessagesDisposable = MetaDisposable() public var selectedMessages = Set() { didSet { if oldValue != self.selectedMessages { self.updateActions() } } } public init(theme: PresentationTheme, strings: PresentationStrings, peerMedia: Bool = false) { self.theme = theme self.peerMedia = peerMedia self.deleteButton = GlassButtonView() self.deleteButton.icon = "Chat/Input/Accessory Panels/MessageSelectionTrash" self.deleteButton.isEnabled = false self.deleteButton.isAccessibilityElement = true self.deleteButton.accessibilityLabel = strings.VoiceOver_MessageContextDelete self.reportButton = GlassButtonView() self.reportButton.icon = "Chat/Input/Accessory Panels/MessageSelectionReport" self.reportButton.isEnabled = false self.reportButton.isAccessibilityElement = true self.reportButton.accessibilityLabel = strings.VoiceOver_MessageContextReport self.forwardButton = GlassButtonView() self.forwardButton.icon = "Chat/Input/Accessory Panels/MessageSelectionForward" self.forwardButton.isAccessibilityElement = true self.forwardButton.accessibilityLabel = strings.VoiceOver_MessageContextForward self.shareButton = GlassButtonView() self.shareButton.icon = "Chat/Input/Accessory Panels/MessageSelectionAction" self.shareButton.isAccessibilityElement = true self.shareButton.accessibilityLabel = strings.VoiceOver_MessageContextShare self.tagButton = GlassButtonView() self.tagButton.icon = "Chat/Input/Accessory Panels/TagIcon" self.tagButton.isAccessibilityElement = true self.tagButton.accessibilityLabel = strings.VoiceOver_MessageSelectionButtonTag self.tagEditButton = GlassButtonView() self.tagEditButton.icon = "Chat/Input/Accessory Panels/TagEditIcon" self.tagEditButton.isAccessibilityElement = true self.tagEditButton.accessibilityLabel = strings.VoiceOver_MessageSelectionButtonTag self.reactionOverlayContainer = ChatMessageSelectionInputPanelNodeViewForOverlayContent() super.init() self.view.addSubview(self.deleteButton) self.view.addSubview(self.reportButton) self.view.addSubview(self.forwardButton) self.view.addSubview(self.shareButton) self.view.addSubview(self.tagButton) self.view.addSubview(self.tagEditButton) self.viewForOverlayContent = self.reactionOverlayContainer self.forwardButton.isImplicitlyDisabled = true self.shareButton.isImplicitlyDisabled = true self.deleteButton.addTarget(self, action: #selector(self.deleteButtonPressed), for: .touchUpInside) self.reportButton.addTarget(self, action: #selector(self.reportButtonPressed), for: .touchUpInside) self.forwardButton.addTarget(self, action: #selector(self.forwardButtonPressed), for: .touchUpInside) self.shareButton.addTarget(self, action: #selector(self.shareButtonPressed), for: .touchUpInside) self.tagButton.addTarget(self, action: #selector(self.tagButtonPressed), for: .touchUpInside) self.tagEditButton.addTarget(self, action: #selector(self.tagButtonPressed), for: .touchUpInside) } deinit { self.canDeleteMessagesDisposable.dispose() } private func updateActions() { self.forwardButton.isEnabled = self.selectedMessages.count != 0 if self.selectedMessages.isEmpty { self.actions = nil if let (width, leftInset, rightInset, bottomInset, additionalSideInsets, maxHeight, maxOverlayHeight, metrics, isSecondary, isMediaInputExpanded) = self.validLayout, let interfaceState = self.presentationInterfaceState { let _ = self.updateLayout(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, additionalSideInsets: additionalSideInsets, maxHeight: maxHeight, maxOverlayHeight: maxOverlayHeight, isSecondary: isSecondary, transition: .immediate, interfaceState: interfaceState, metrics: metrics, isMediaInputExpanded: isMediaInputExpanded) } self.canDeleteMessagesDisposable.set(nil) } else if let context = self.context { self.canDeleteMessagesDisposable.set((context.sharedContext.chatAvailableMessageActions(engine: context.engine, accountPeerId: context.account.peerId, messageIds: self.selectedMessages, keepUpdated: true) |> deliverOnMainQueue).startStrict(next: { [weak self] actions in if let strongSelf = self { strongSelf.actions = actions if let (width, leftInset, rightInset, bottomInset, additionalSideInsets, maxHeight, maxOverlayHeight: maxOverlayHeight, metrics, isSecondary, isMediaInputExpanded) = strongSelf.validLayout, let interfaceState = strongSelf.presentationInterfaceState { let _ = strongSelf.updateLayout(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, additionalSideInsets: additionalSideInsets, maxHeight: maxHeight, maxOverlayHeight: maxOverlayHeight, isSecondary: isSecondary, transition: .immediate, interfaceState: interfaceState, metrics: metrics, isMediaInputExpanded: isMediaInputExpanded) } } })) } } public func updateTheme(theme: PresentationTheme) { if self.theme !== theme { self.theme = theme } } @objc private func deleteButtonPressed() { self.interfaceInteraction?.deleteSelectedMessages() } @objc private func reportButtonPressed() { self.interfaceInteraction?.reportSelectedMessages() } @objc private func forwardButtonPressed() { if let _ = self.presentationInterfaceState?.renderedPeer?.peer as? TelegramSecretChat { return } if let actions = self.actions, actions.isCopyProtected { self.interfaceInteraction?.displayCopyProtectionTip(self.forwardButton, false) } else if !self.forwardButton.isImplicitlyDisabled { self.interfaceInteraction?.forwardSelectedMessages() } } @objc private func shareButtonPressed() { if let _ = self.presentationInterfaceState?.renderedPeer?.peer as? TelegramSecretChat { return } if let actions = self.actions, actions.isCopyProtected { self.interfaceInteraction?.displayCopyProtectionTip(self.shareButton, true) } else if !self.shareButton.isImplicitlyDisabled { self.interfaceInteraction?.shareSelectedMessages() } } @objc private func tagButtonPressed() { guard let context = self.context else { return } if self.reactionOverlayContainer.reactionContextNode != nil { return } let reactionItems: Signal<[ReactionItem], NoError> = tagMessageReactions(context: context, subPeerId: self.presentationInterfaceState?.chatLocation.threadId.flatMap(EnginePeer.Id.init)) let _ = (reactionItems |> take(1) |> deliverOnMainQueue).start(next: { [weak self] reactionItems in guard let self, let actions = self.actions, let context = self.context else { return } let presentationData = context.sharedContext.currentPresentationData.with { $0 } let reactionContextNode = ReactionContextNode( context: context, animationCache: context.animationCache, presentationData: presentationData, items: reactionItems.map { ReactionContextItem.reaction(item: $0, icon: .none) }, selectedItems: actions.editTags, title: actions.editTags.isEmpty ? presentationData.strings.Chat_ReactionSelectionTitleAddTag : presentationData.strings.Chat_ReactionSelectionTitleEditTag, reactionsLocked: false, alwaysAllowPremiumReactions: false, allPresetReactionsAreAvailable: true, getEmojiContent: { animationCache, animationRenderer in let mappedReactionItems: [EmojiComponentReactionItem] = reactionItems.map { reaction -> EmojiComponentReactionItem in return EmojiComponentReactionItem(reaction: reaction.reaction.rawValue, file: reaction.stillAnimation) } return EmojiPagerContentComponent.emojiInputData( context: context, animationCache: animationCache, animationRenderer: animationRenderer, isStandalone: false, subject: .messageTag, hasTrending: false, topReactionItems: mappedReactionItems, areUnicodeEmojiEnabled: false, areCustomEmojiEnabled: true, chatPeerId: context.account.peerId, selectedItems: Set(), premiumIfSavedMessages: false ) }, isExpandedUpdated: { [weak self] transition in guard let self else { return } self.update(transition: transition) }, requestLayout: { [weak self] transition in guard let self else { return } self.update(transition: transition) }, requestUpdateOverlayWantsToBeBelowKeyboard: { [weak self] transition in guard let self else { return } self.update(transition: transition) } ) reactionContextNode.reactionSelected = { [weak self] updateReaction, _ in guard let self, let context = self.context, let presentationInterfaceState = self.presentationInterfaceState, let actions = self.actions else { return } self.interfaceInteraction?.cancelMessageSelection(.animated(duration: 0.4, curve: .spring)) if actions.editTags.contains(updateReaction.reaction) { var reactions = actions.editTags reactions.remove(updateReaction.reaction) let mappedUpdatedReactions = reactions.map { reaction -> UpdateMessageReaction in switch reaction { case let .builtin(value): return .builtin(value) case let .custom(fileId): return .custom(fileId: fileId, file: nil) case .stars: return .stars } } if let selectionState = presentationInterfaceState.interfaceState.selectionState { context.engine.messages.setMessageReactions(ids: Array(selectionState.selectedIds), reactions: mappedUpdatedReactions) } else { context.engine.messages.setMessageReactions(ids: Array(self.selectedMessages), reactions: mappedUpdatedReactions) } } else { if let selectionState = presentationInterfaceState.interfaceState.selectionState { context.engine.messages.addMessageReactions(ids: Array(selectionState.selectedIds), reactions: [updateReaction]) } else { context.engine.messages.addMessageReactions(ids: Array(self.selectedMessages), reactions: [updateReaction]) } } self.reactionOverlayContainer.dismissReactionSelection() } reactionContextNode.displayTail = true reactionContextNode.forceTailToRight = true reactionContextNode.forceDark = false self.reactionOverlayContainer.reactionContextNode = reactionContextNode self.reactionOverlayContainer.addSubnode(reactionContextNode) self.update(transition: .immediate) }) } private func update(transition: ContainedViewLayoutTransition) { if let (width, leftInset, rightInset, bottomInset, additionalSideInsets, maxHeight, maxOverlayHeight, metrics, isSecondary, isMediaInputExpanded) = self.validLayout, let interfaceState = self.presentationInterfaceState { let _ = self.updateLayout(width: width, leftInset: leftInset, rightInset: rightInset, bottomInset: bottomInset, additionalSideInsets: additionalSideInsets, maxHeight: maxHeight, maxOverlayHeight: maxOverlayHeight, isSecondary: isSecondary, transition: transition, interfaceState: interfaceState, metrics: metrics, isMediaInputExpanded: isMediaInputExpanded) } } override public func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, maxOverlayHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat { self.validLayout = (width, leftInset, rightInset, bottomInset, additionalSideInsets, maxHeight, maxOverlayHeight, metrics, isSecondary, isMediaInputExpanded) var leftInset = leftInset leftInset += 16.0 var rightInset = rightInset rightInset += 16.0 let panelHeight = defaultHeight(metrics: metrics) if self.presentationInterfaceState != interfaceState { self.presentationInterfaceState = interfaceState } if let actions = self.actions { self.deleteButton.isEnabled = false self.reportButton.isEnabled = false self.forwardButton.isImplicitlyDisabled = !actions.options.contains(.forward) if self.peerMedia { self.deleteButton.isEnabled = !actions.options.intersection([.deleteLocally, .deleteGlobally]).isEmpty } else { self.deleteButton.isEnabled = !actions.disableDelete } self.shareButton.isImplicitlyDisabled = actions.options.intersection(.forward).isEmpty || actions.options.intersection(.externalShare).isEmpty self.reportButton.isEnabled = !actions.options.intersection([.report]).isEmpty if self.peerMedia { self.deleteButton.isHidden = !self.deleteButton.isEnabled } else { self.deleteButton.isHidden = false } self.reportButton.isHidden = !self.reportButton.isEnabled if actions.setTag { if !actions.editTags.isEmpty { self.tagButton.isHidden = true self.tagEditButton.isHidden = false } else { self.tagButton.isHidden = false self.tagEditButton.isHidden = true } } else { self.tagButton.isHidden = true self.tagEditButton.isHidden = true } } else { self.deleteButton.isEnabled = false self.deleteButton.isHidden = self.peerMedia self.reportButton.isEnabled = false self.reportButton.isHidden = true self.forwardButton.isImplicitlyDisabled = true self.shareButton.isImplicitlyDisabled = true self.tagButton.isHidden = true self.tagEditButton.isHidden = true self.tagButton.isHidden = true self.tagEditButton.isHidden = true } if self.reportButton.isHidden || (self.peerMedia && self.deleteButton.isHidden && self.reportButton.isHidden) { if let peer = interfaceState.renderedPeer?.peer as? TelegramChannel, case .broadcast = peer.info { self.reportButton.isHidden = false } else if self.peerMedia { self.deleteButton.isHidden = false } } var width = width if additionalSideInsets.right > 0.0 { width -= additionalSideInsets.right } var tagButton: GlassButtonView? if !self.tagButton.isHidden { tagButton = self.tagButton } else if !self.tagEditButton.isHidden { tagButton = self.tagEditButton } let buttons: [GlassButtonView] if self.reportButton.isHidden { if let tagButton { buttons = [ self.deleteButton, tagButton, self.shareButton, self.forwardButton ] } else { buttons = [ self.deleteButton, self.shareButton, self.forwardButton ] } } else if !self.deleteButton.isHidden { if let tagButton { buttons = [ self.deleteButton, self.reportButton, tagButton, self.shareButton, self.forwardButton ] } else { buttons = [ self.deleteButton, self.reportButton, self.shareButton, self.forwardButton ] } } else { if let tagButton { buttons = [ self.deleteButton, self.reportButton, tagButton, self.shareButton, self.forwardButton ] } else { buttons = [ self.deleteButton, self.reportButton, self.shareButton, self.forwardButton ] } } let buttonSize = CGSize(width: 40.0, height: 40.0) let availableWidth = width - leftInset - rightInset let spacing: CGFloat = floor((availableWidth - buttonSize.width * CGFloat(buttons.count)) / CGFloat(buttons.count - 1)) var offset: CGFloat = leftInset for i in 0 ..< buttons.count { let button = buttons[i] let buttonFrame: CGRect if i == buttons.count - 1 { buttonFrame = CGRect(origin: CGPoint(x: width - rightInset - buttonSize.width, y: 0.0), size: buttonSize) } else { buttonFrame = CGRect(origin: CGPoint(x: offset, y: 0.0), size: buttonSize) } transition.updateFrame(view: button, frame: buttonFrame) button.update(theme: interfaceState.theme, size: buttonFrame.size, transition: ComponentTransition(transition)) offset += buttonSize.width + spacing } if let reactionContextNode = self.reactionOverlayContainer.reactionContextNode, let tagButton { let isFirstTime = reactionContextNode.bounds.isEmpty let size = CGSize(width: width, height: maxHeight) let reactionsAnchorRect = tagButton.frame.offsetBy(dx: -54.0, dy: -(panelHeight - size.height) + 14.0) transition.updateFrame(node: reactionContextNode, frame: CGRect(origin: CGPoint(x: 0.0, y: panelHeight - size.height), size: size)) reactionContextNode.updateLayout(size: size, insets: UIEdgeInsets(), anchorRect: reactionsAnchorRect, centerAligned: true, isCoveredByInput: false, isAnimatingOut: false, transition: transition) reactionContextNode.updateIsIntersectingContent(isIntersectingContent: true, transition: .immediate) if isFirstTime { reactionContextNode.animateIn(from: reactionsAnchorRect) } } return panelHeight } override public func minimalHeight(interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics) -> CGFloat { return defaultHeight(metrics: metrics) } }