import Foundation import AsyncDisplayKit import Display import TelegramCore import TelegramPresentationData import TextSelectionNode import Markdown import AppBundle import TextFormat import TextNodeWithEntities import SwiftSignalKit private final class ContextActionsSelectionGestureRecognizer: UIPanGestureRecognizer { var updateLocation: ((CGPoint, Bool) -> Void)? var completed: ((Bool) -> Void)? override func touchesBegan(_ touches: Set, with event: UIEvent) { super.touchesBegan(touches, with: event) self.updateLocation?(touches.first!.location(in: self.view), false) } override func touchesMoved(_ touches: Set, with event: UIEvent) { super.touchesMoved(touches, with: event) self.updateLocation?(touches.first!.location(in: self.view), true) } override func touchesEnded(_ touches: Set, with event: UIEvent) { super.touchesEnded(touches, with: event) self.completed?(true) } override func touchesCancelled(_ touches: Set, with event: UIEvent) { super.touchesCancelled(touches, with: event) self.completed?(false) } } private enum ContextItemNode { case action(ContextActionNode) case custom(ContextMenuCustomNode) case itemSeparator(ASDisplayNode) case separator(ASDisplayNode) } private final class InnerActionsContainerNode: ASDisplayNode { private let blurBackground: Bool private let presentationData: PresentationData private let containerNode: ASDisplayNode private var effectView: UIVisualEffectView? private var itemNodes: [ContextItemNode] private let feedbackTap: () -> Void private(set) var gesture: UIGestureRecognizer? private var currentHighlightedActionNode: ContextActionNodeProtocol? var panSelectionGestureEnabled: Bool = true { didSet { if self.panSelectionGestureEnabled != oldValue, let gesture = self.gesture { gesture.isEnabled = self.panSelectionGestureEnabled self.itemNodes.forEach({ itemNode in switch itemNode { case let .action(actionNode): actionNode.isUserInteractionEnabled = !self.panSelectionGestureEnabled default: break } }) } } } init(presentationData: PresentationData, items: [ContextMenuItem], getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { self.presentationData = presentationData self.feedbackTap = feedbackTap self.blurBackground = blurBackground self.containerNode = ASDisplayNode() self.containerNode.clipsToBounds = true self.containerNode.cornerRadius = 14.0 self.containerNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor var requestUpdateAction: ((AnyHashable, ContextMenuActionItem) -> Void)? var itemNodes: [ContextItemNode] = [] for i in 0 ..< items.count { switch items[i] { case let .action(action): itemNodes.append(.action(ContextActionNode(presentationData: presentationData, action: action, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, requestUpdateAction: { id, action in requestUpdateAction?(id, action) }))) if i != items.count - 1 { switch items[i + 1] { case .action, .custom: let separatorNode = ASDisplayNode() separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor itemNodes.append(.itemSeparator(separatorNode)) default: break } } case let .custom(item, _): let itemNode = item.node(presentationData: presentationData, getController: getController, actionSelected: actionSelected) itemNodes.append(.custom(itemNode)) if i != items.count - 1 && itemNode.needsSeparator { switch items[i + 1] { case .action, .custom: let separatorNode = ASDisplayNode() separatorNode.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor itemNodes.append(.itemSeparator(separatorNode)) default: break } } case .separator: let separatorNode = ASDisplayNode() separatorNode.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor itemNodes.append(.separator(separatorNode)) } } self.itemNodes = itemNodes super.init() requestUpdateAction = { [weak self] id, action in guard let strongSelf = self else { return } loop: for itemNode in strongSelf.itemNodes { switch itemNode { case let .action(contextActionNode): if contextActionNode.action.id == id { contextActionNode.updateAction(item: action) break loop } default: break } } } self.addSubnode(self.containerNode) self.itemNodes.forEach({ itemNode in switch itemNode { case let .action(actionNode): actionNode.isUserInteractionEnabled = false self.containerNode.addSubnode(actionNode) case let .custom(itemNode): self.containerNode.addSubnode(itemNode) case let .itemSeparator(separatorNode): self.containerNode.addSubnode(separatorNode) case let .separator(separatorNode): self.containerNode.addSubnode(separatorNode) } }) let gesture = ContextActionsSelectionGestureRecognizer(target: nil, action: nil) self.gesture = gesture gesture.updateLocation = { [weak self] point, moved in guard let strongSelf = self else { return } var actionNode = strongSelf.actionNode(at: point) if let actionNodeValue = actionNode, !actionNodeValue.isActionEnabled { actionNode = nil } if actionNode !== strongSelf.currentHighlightedActionNode { if actionNode != nil, moved { strongSelf.feedbackTap() } strongSelf.currentHighlightedActionNode?.setIsHighlighted(false) } strongSelf.currentHighlightedActionNode = actionNode actionNode?.setIsHighlighted(true) } gesture.completed = { [weak self] performAction in guard let strongSelf = self else { return } if let currentHighlightedActionNode = strongSelf.currentHighlightedActionNode { strongSelf.currentHighlightedActionNode = nil currentHighlightedActionNode.setIsHighlighted(false) if performAction { currentHighlightedActionNode.performAction() } } } self.view.addGestureRecognizer(gesture) gesture.isEnabled = self.panSelectionGestureEnabled } func updateLayout(widthClass: ContainerViewLayoutSizeClass, constrainedWidth: CGFloat, constrainedHeight: CGFloat, minimalWidth: CGFloat?, transition: ContainedViewLayoutTransition) -> CGSize { var minActionsWidth: CGFloat = 250.0 if let minimalWidth = minimalWidth, minimalWidth > minActionsWidth { minActionsWidth = minimalWidth } switch widthClass { case .compact: minActionsWidth = max(minActionsWidth, floor(constrainedWidth / 3.0)) if let effectView = self.effectView { self.effectView = nil effectView.removeFromSuperview() } case .regular: if self.effectView == nil { let effectView: UIVisualEffectView if #available(iOS 13.0, *) { if self.presentationData.theme.rootController.keyboardColor == .dark { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) } else { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialLight)) } } else if #available(iOS 10.0, *) { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) } else { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) } self.effectView = effectView self.containerNode.view.insertSubview(effectView, at: 0) } } minActionsWidth = min(minActionsWidth, constrainedWidth) let separatorHeight: CGFloat = 8.0 var maxWidth: CGFloat = 0.0 var contentHeight: CGFloat = 0.0 var heightsAndCompletions: [(CGFloat, (CGSize, ContainedViewLayoutTransition) -> Void)?] = [] for i in 0 ..< self.itemNodes.count { switch self.itemNodes[i] { case let .action(itemNode): let previous: ContextActionSibling let next: ContextActionSibling if i == 0 { previous = .none } else if case .separator = self.itemNodes[i - 1] { previous = .separator } else { previous = .item } if i == self.itemNodes.count - 1 { next = .none } else if case .separator = self.itemNodes[i + 1] { next = .separator } else { next = .item } let (minSize, complete) = itemNode.updateLayout(constrainedWidth: constrainedWidth, previous: previous, next: next) maxWidth = max(maxWidth, minSize.width) heightsAndCompletions.append((minSize.height, complete)) contentHeight += minSize.height case let .custom(itemNode): let (minSize, complete) = itemNode.updateLayout(constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight) maxWidth = max(maxWidth, minSize.width) heightsAndCompletions.append((minSize.height, complete)) contentHeight += minSize.height case .itemSeparator: heightsAndCompletions.append(nil) contentHeight += UIScreenPixel case .separator: heightsAndCompletions.append(nil) contentHeight += separatorHeight } } maxWidth = max(maxWidth, minActionsWidth) var verticalOffset: CGFloat = 0.0 for i in 0 ..< heightsAndCompletions.count { switch self.itemNodes[i] { case let .action(itemNode): if let (itemHeight, itemCompletion) = heightsAndCompletions[i] { let itemSize = CGSize(width: maxWidth, height: itemHeight) transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: itemSize)) itemCompletion(itemSize, transition) verticalOffset += itemHeight } case let .custom(itemNode): if let (itemHeight, itemCompletion) = heightsAndCompletions[i] { let itemSize = CGSize(width: maxWidth, height: itemHeight) transition.updateFrame(node: itemNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: itemSize)) itemCompletion(itemSize, transition) verticalOffset += itemHeight } case let .itemSeparator(separatorNode): transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: maxWidth, height: UIScreenPixel))) verticalOffset += UIScreenPixel case let .separator(separatorNode): transition.updateFrame(node: separatorNode, frame: CGRect(origin: CGPoint(x: 0.0, y: verticalOffset), size: CGSize(width: maxWidth, height: separatorHeight))) verticalOffset += separatorHeight } } let size = CGSize(width: maxWidth, height: verticalOffset) let bounds = CGRect(origin: CGPoint(), size: size) transition.updateFrame(node: self.containerNode, frame: bounds) if let effectView = self.effectView { transition.updateFrame(view: effectView, frame: bounds) } return size } func updateTheme(presentationData: PresentationData) { for itemNode in self.itemNodes { switch itemNode { case let .action(action): action.updateTheme(presentationData: presentationData) case let .custom(item): item.updateTheme(presentationData: presentationData) case let .separator(separator): separator.backgroundColor = presentationData.theme.contextMenu.sectionSeparatorColor case let .itemSeparator(itemSeparator): itemSeparator.backgroundColor = presentationData.theme.contextMenu.itemSeparatorColor } } self.containerNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor } func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? { for itemNode in self.itemNodes { switch itemNode { case let .action(actionNode): if actionNode.frame.contains(point) { return actionNode } case let .custom(node): if let node = node as? ContextActionNodeProtocol, node.frame.contains(point) { return node.actionNode(at: self.convert(point, to: node)) } default: break } } return nil } } final class InnerTextSelectionTipContainerNode: ASDisplayNode { private let presentationData: PresentationData let shadowNode: ASImageNode private let backgroundNode: ASDisplayNode private var effectView: UIVisualEffectView? private let highlightBackgroundNode: ASDisplayNode private let buttonNode: HighlightTrackingButtonNode private let textNode: TextNodeWithEntities private var textSelectionNode: TextSelectionNode? private let iconNode: ASImageNode private let placeholderNode: ASDisplayNode var tip: ContextController.Tip private let text: String private var arguments: TextNodeWithEntities.Arguments? private var file: TelegramMediaFile? private let targetSelectionIndex: Int? private var hapticFeedback: HapticFeedback? private var action: (() -> Void)? var requestDismiss: (@escaping () -> Void) -> Void = { _ in } init(presentationData: PresentationData, tip: ContextController.Tip) { self.tip = tip self.presentationData = presentationData self.shadowNode = ASImageNode() self.shadowNode.displaysAsynchronously = false self.shadowNode.displayWithoutProcessing = true self.shadowNode.image = UIImage(bundleImageName: "Components/Context Menu/Shadow")?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 60) self.shadowNode.contentMode = .scaleToFill self.shadowNode.isHidden = true self.backgroundNode = ASDisplayNode() self.highlightBackgroundNode = ASDisplayNode() self.highlightBackgroundNode.isAccessibilityElement = false self.highlightBackgroundNode.alpha = 0.0 self.buttonNode = HighlightTrackingButtonNode() self.textNode = TextNodeWithEntities() self.textNode.textNode.displaysAsynchronously = false self.textNode.textNode.isUserInteractionEnabled = false var isUserInteractionEnabled = false var icon: UIImage? switch tip { case .textSelection: var rawText = self.presentationData.strings.ChatContextMenu_TextSelectionTip2 if let range = rawText.range(of: "|") { rawText.removeSubrange(range) self.text = rawText self.targetSelectionIndex = NSRange(range, in: rawText).lowerBound } else { self.text = rawText self.targetSelectionIndex = 1 } icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") case .quoteSelection: var rawText = presentationData.strings.ChatContextMenu_QuoteSelectionTip if let range = rawText.range(of: "|") { rawText.removeSubrange(range) self.text = rawText self.targetSelectionIndex = NSRange(range, in: rawText).lowerBound } else { self.text = rawText self.targetSelectionIndex = 1 } icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") case .messageViewsPrivacy: self.text = self.presentationData.strings.ChatContextMenu_MessageViewsPrivacyTip self.targetSelectionIndex = nil icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") case let .messageCopyProtection(isChannel): self.text = isChannel ? self.presentationData.strings.Conversation_CopyProtectionInfoChannel : self.presentationData.strings.Conversation_CopyProtectionInfoGroup self.targetSelectionIndex = nil icon = UIImage(bundleImageName: "Chat/Context Menu/ReportCopyright") case let .animatedEmoji(text, arguments, file, action): self.action = action self.text = text ?? "" self.arguments = arguments self.file = file self.targetSelectionIndex = nil icon = nil isUserInteractionEnabled = text != nil case let .notificationTopicExceptions(text, action): self.action = action self.text = text self.targetSelectionIndex = nil icon = nil isUserInteractionEnabled = action != nil case let .starsReactions(topCount): self.action = nil self.text = self.presentationData.strings.Chat_SendStarsToBecomeTopInfo("\(topCount)").string self.targetSelectionIndex = nil icon = nil isUserInteractionEnabled = action != nil case .videoProcessing: self.action = nil self.text = self.presentationData.strings.Chat_VideoProcessingInfo self.targetSelectionIndex = nil icon = nil isUserInteractionEnabled = action != nil case .collageReordering: self.action = nil self.text = self.presentationData.strings.Camera_CollageReorderingInfo self.targetSelectionIndex = nil icon = UIImage(bundleImageName: "Chat/Context Menu/Tip") } self.iconNode = ASImageNode() self.iconNode.displaysAsynchronously = false self.iconNode.displayWithoutProcessing = true self.iconNode.image = generateTintedImage(image: icon, color: presentationData.theme.contextMenu.primaryColor) self.placeholderNode = ASDisplayNode() self.placeholderNode.clipsToBounds = true self.placeholderNode.cornerRadius = 4.0 self.placeholderNode.isUserInteractionEnabled = false super.init() self.backgroundNode.backgroundColor = presentationData.theme.contextMenu.backgroundColor self.backgroundNode.clipsToBounds = true self.backgroundNode.cornerRadius = 14.0 self.highlightBackgroundNode.clipsToBounds = true self.highlightBackgroundNode.cornerRadius = 14.0 let textSelectionNode = TextSelectionNode(theme: TextSelectionTheme(selection: presentationData.theme.contextMenu.primaryColor.withAlphaComponent(0.15), knob: presentationData.theme.contextMenu.primaryColor, knobDiameter: 8.0, isDark: presentationData.theme.overallDarkAppearance), strings: presentationData.strings, textNode: self.textNode.textNode, updateIsActive: { _ in }, present: { _, _ in }, rootNode: { [weak self] in return self }, performAction: { _, _ in }) self.textSelectionNode = textSelectionNode self.addSubnode(self.backgroundNode) self.addSubnode(self.highlightBackgroundNode) self.addSubnode(self.textNode.textNode) self.addSubnode(self.iconNode) self.addSubnode(self.placeholderNode) self.textSelectionNode.flatMap(self.addSubnode) self.addSubnode(textSelectionNode.highlightAreaNode) self.addSubnode(self.buttonNode) self.buttonNode.highligthedChanged = { [weak self] highlighted in guard let strongSelf = self else { return } strongSelf.isButtonHighlighted = highlighted strongSelf.updateHighlight(animated: false) } self.buttonNode.addTarget(self, action: #selector(self.pressed), forControlEvents: .touchUpInside) let shimmeringForegroundColor: UIColor if presentationData.theme.overallDarkAppearance { let backgroundColor = presentationData.theme.contextMenu.backgroundColor.blitOver(presentationData.theme.list.plainBackgroundColor, alpha: 1.0) shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.blitOver(backgroundColor, alpha: 0.1) } else { shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07) } self.placeholderNode.backgroundColor = shimmeringForegroundColor self.isUserInteractionEnabled = isUserInteractionEnabled } @objc func pressed() { self.requestDismiss({ self.action?() }) } func animateTransitionInside(other: InnerTextSelectionTipContainerNode) { let nodes: [ASDisplayNode] = [ self.textNode.textNode, self.iconNode, self.placeholderNode ] for node in nodes { other.addSubnode(node) node.layer.animateAlpha(from: node.alpha, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak node] _ in node?.removeFromSupernode() }) } } func animateContentIn() { let nodes: [ASDisplayNode] = [ self.textNode.textNode, self.iconNode ] for node in nodes { node.layer.animateAlpha(from: 0.0, to: node.alpha, duration: 0.25) } } func updateLayout(widthClass: ContainerViewLayoutSizeClass, presentation: ContextControllerActionsStackNode.Presentation, width: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize { var needsBlur = false if case .regular = widthClass { needsBlur = true } else if case .inline = presentation { needsBlur = true } if !needsBlur { if let effectView = self.effectView { self.effectView = nil effectView.removeFromSuperview() } } else { if self.effectView == nil { let effectView: UIVisualEffectView if #available(iOS 13.0, *) { if self.presentationData.theme.overallDarkAppearance { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark)) } else { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialLight)) } } else if #available(iOS 10.0, *) { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) } else { effectView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) } effectView.clipsToBounds = true effectView.layer.cornerRadius = self.backgroundNode.cornerRadius self.effectView = effectView self.view.insertSubview(effectView, at: 0) } } let verticalInset: CGFloat = 10.0 let horizontalInset: CGFloat = 16.0 let standardIconWidth: CGFloat = 32.0 let iconSideInset: CGFloat = 12.0 let textFont = Font.regular(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0)) let boldTextFont = Font.bold(floor(presentationData.listsFontSize.baseDisplaySize * 14.0 / 17.0)) let textColor = self.presentationData.theme.contextMenu.primaryColor let linkColor = self.presentationData.theme.overallDarkAppearance ? UIColor(rgb: 0x64d2ff) : self.presentationData.theme.contextMenu.badgeFillColor let iconSize = self.iconNode.image?.size ?? CGSize(width: 16.0, height: 16.0) let text = self.text.replacingOccurrences(of: "#", with: "# ") let attributedText = NSMutableAttributedString(attributedString: parseMarkdownIntoAttributedString(text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: textColor), bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor), link: MarkdownAttributeSet(font: boldTextFont, textColor: linkColor), linkAttribute: { _ in return nil }))) if let file = self.file { let range = (attributedText.string as NSString).range(of: "#") if range.location != NSNotFound { attributedText.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: file.fileId.id, file: file), range: range) } } let shimmeringForegroundColor: UIColor if presentationData.theme.overallDarkAppearance { let backgroundColor = presentationData.theme.contextMenu.backgroundColor.blitOver(presentationData.theme.list.plainBackgroundColor, alpha: 1.0) shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.blitOver(backgroundColor, alpha: 0.1) } else { shimmeringForegroundColor = presentationData.theme.contextMenu.primaryColor.withMultipliedAlpha(0.07) } let textRightInset: CGFloat if let _ = self.iconNode.image { textRightInset = iconSize.width - 2.0 } else { textRightInset = 0.0 } let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode) let (textLayout, textApply) = makeTextLayout(TextNodeLayoutArguments(attributedString: attributedText, backgroundColor: nil, minimumNumberOfLines: 0, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: width - horizontalInset * 2.0 - textRightInset, height: .greatestFiniteMagnitude), alignment: .left, lineSpacing: 0.0, cutout: nil, insets: UIEdgeInsets(), lineColor: nil, textShadowColor: nil, textStroke: nil)) let _ = textApply(self.arguments?.withUpdatedPlaceholderColor(shimmeringForegroundColor)) let textFrame = CGRect(origin: CGPoint(x: horizontalInset, y: verticalInset), size: textLayout.size) transition.updateFrame(node: self.textNode.textNode, frame: textFrame) if textFrame.size.height.isZero { self.textNode.textNode.alpha = 0.0 } else if self.textNode.textNode.alpha.isZero { self.textNode.textNode.alpha = 1.0 self.textNode.textNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) self.placeholderNode.layer.animateAlpha(from: self.placeholderNode.alpha, to: 1.0, duration: 0.2) } self.textNode.visibilityRect = CGRect.infinite var contentHeight = textLayout.size.height if contentHeight.isZero { contentHeight = 32.0 } let size = CGSize(width: width, height: contentHeight + verticalInset * 2.0) let lineHeight: CGFloat = 8.0 transition.updateFrame(node: self.placeholderNode, frame: CGRect(origin: CGPoint(x: horizontalInset, y: floorToScreenPixels((size.height - lineHeight) / 2.0)), size: CGSize(width: width - horizontalInset * 2.0, height: lineHeight))) transition.updateAlpha(node: self.placeholderNode, alpha: textFrame.height.isZero ? 1.0 : 0.0) let iconFrame = CGRect(origin: CGPoint(x: size.width - standardIconWidth - iconSideInset + floor((standardIconWidth - iconSize.width) / 2.0), y: floor((size.height - iconSize.height) / 2.0)), size: iconSize) transition.updateFrame(node: self.iconNode, frame: iconFrame) if let textSelectionNode = self.textSelectionNode { transition.updateFrame(node: textSelectionNode, frame: textFrame) textSelectionNode.highlightAreaNode.frame = textFrame } switch presentation { case .modal: self.shadowNode.isHidden = true case .inline, .additional: self.shadowNode.isHidden = false } if let effectView = self.effectView { transition.updateFrame(view: effectView, frame: CGRect(origin: CGPoint(), size: size)) } self.highlightBackgroundNode.backgroundColor = presentationData.theme.contextMenu.itemHighlightedBackgroundColor return size } func setActualSize(size: CGSize, transition: ContainedViewLayoutTransition) { transition.updateFrame(node: self.backgroundNode, frame: CGRect(origin: CGPoint(), size: size)) self.highlightBackgroundNode.frame = CGRect(origin: CGPoint(), size: size) self.buttonNode.frame = CGRect(origin: CGPoint(), size: size) } func updateTheme(presentationData: PresentationData) { self.backgroundColor = presentationData.theme.contextMenu.backgroundColor } func animateIn() { if let textSelectionNode = self.textSelectionNode, let targetSelectionIndex = self.targetSelectionIndex { textSelectionNode.pretendInitiateSelection() DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5, execute: { [weak self] in guard let strongSelf = self else { return } strongSelf.textSelectionNode?.pretendExtendSelection(to: targetSelectionIndex) }) } } func updateHighlight(animated: Bool) { if self.isButtonHighlighted || self.isHighlighted { self.highlightBackgroundNode.alpha = 1.0 } else { if animated { let previousAlpha = self.highlightBackgroundNode.alpha self.highlightBackgroundNode.alpha = 0.0 self.highlightBackgroundNode.layer.animateAlpha(from: previousAlpha, to: 0.0, duration: 0.2) } else { self.highlightBackgroundNode.alpha = 0.0 } } } private var isButtonHighlighted = false private var isHighlighted = false func setHighlighted(_ highlighted: Bool) { guard self.isHighlighted != highlighted else { return } self.isHighlighted = highlighted if highlighted { if self.hapticFeedback == nil { self.hapticFeedback = HapticFeedback() } self.hapticFeedback?.tap() } self.updateHighlight(animated: false) } func highlightGestureMoved(location: CGPoint) { if self.bounds.contains(location) && self.isUserInteractionEnabled { self.setHighlighted(true) } else { self.setHighlighted(false) } } func highlightGestureFinished(performAction: Bool) { if self.isHighlighted { self.setHighlighted(false) if performAction { self.pressed() } } } } final class ContextActionsContainerNode: ASDisplayNode { private let presentationData: PresentationData private let getController: () -> ContextControllerProtocol? private let blurBackground: Bool private let shadowNode: ASImageNode private let additionalShadowNode: ASImageNode? private let additionalActionsNode: InnerActionsContainerNode? private let actionsNode: InnerActionsContainerNode private let scrollNode: ASScrollNode private var tip: ContextController.Tip? private var textSelectionTipNode: InnerTextSelectionTipContainerNode? private var textSelectionTipNodeDisposable: Disposable? var panSelectionGestureEnabled: Bool = true { didSet { if self.panSelectionGestureEnabled != oldValue { self.actionsNode.panSelectionGestureEnabled = self.panSelectionGestureEnabled } } } var hasAdditionalActions: Bool { return self.additionalActionsNode != nil } init(presentationData: PresentationData, items: ContextController.Items, getController: @escaping () -> ContextControllerProtocol?, actionSelected: @escaping (ContextMenuActionResult) -> Void, requestLayout: @escaping () -> Void, feedbackTap: @escaping () -> Void, blurBackground: Bool) { self.presentationData = presentationData self.getController = getController self.blurBackground = blurBackground self.shadowNode = ASImageNode() self.shadowNode.displaysAsynchronously = false self.shadowNode.displayWithoutProcessing = true self.shadowNode.image = UIImage(bundleImageName: "Components/Context Menu/Shadow")?.stretchableImage(withLeftCapWidth: 60, topCapHeight: 60) self.shadowNode.contentMode = .scaleToFill self.shadowNode.isHidden = true var items = items if case var .list(itemList) = items.content, let firstItem = itemList.first, case let .custom(_, additional) = firstItem, additional { let additionalShadowNode = ASImageNode() additionalShadowNode.displaysAsynchronously = false additionalShadowNode.displayWithoutProcessing = true additionalShadowNode.image = self.shadowNode.image additionalShadowNode.contentMode = .scaleToFill additionalShadowNode.isHidden = true self.additionalShadowNode = additionalShadowNode self.additionalActionsNode = InnerActionsContainerNode(presentationData: presentationData, items: [firstItem], getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) itemList.removeFirst() items.content = .list(itemList) } else { self.additionalShadowNode = nil self.additionalActionsNode = nil } var itemList: [ContextMenuItem] = [] if case let .list(list) = items.content { itemList = list } self.actionsNode = InnerActionsContainerNode(presentationData: presentationData, items: itemList, getController: getController, actionSelected: actionSelected, requestLayout: requestLayout, feedbackTap: feedbackTap, blurBackground: blurBackground) self.tip = items.tip self.scrollNode = ASScrollNode() self.scrollNode.canCancelAllTouchesInViews = true self.scrollNode.view.delaysContentTouches = false self.scrollNode.view.showsVerticalScrollIndicator = false if #available(iOS 11.0, *) { self.scrollNode.view.contentInsetAdjustmentBehavior = .never } super.init() self.addSubnode(self.shadowNode) self.additionalShadowNode.flatMap(self.addSubnode) self.additionalActionsNode.flatMap(self.scrollNode.addSubnode) self.scrollNode.addSubnode(self.actionsNode) self.addSubnode(self.scrollNode) if let tipSignal = items.tipSignal { self.textSelectionTipNodeDisposable = (tipSignal |> deliverOnMainQueue).start(next: { [weak self] tip in guard let strongSelf = self else { return } strongSelf.tip = tip requestLayout() }).strict() } } deinit { self.textSelectionTipNodeDisposable?.dispose() } func updateLayout(widthClass: ContainerViewLayoutSizeClass, presentation: ContextControllerActionsStackNode.Presentation, constrainedWidth: CGFloat, constrainedHeight: CGFloat, transition: ContainedViewLayoutTransition) -> CGSize { var widthClass = widthClass if !self.blurBackground { widthClass = .regular } var contentSize = CGSize() let actionsSize = self.actionsNode.updateLayout(widthClass: widthClass, constrainedWidth: constrainedWidth, constrainedHeight: constrainedHeight, minimalWidth: nil, transition: transition) if let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode { let additionalActionsSize = additionalActionsNode.updateLayout(widthClass: widthClass, constrainedWidth: actionsSize.width, constrainedHeight: constrainedHeight, minimalWidth: actionsSize.width, transition: transition) contentSize = additionalActionsSize let bounds = CGRect(origin: CGPoint(), size: additionalActionsSize) transition.updateFrame(node: additionalShadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0)) additionalShadowNode.isHidden = widthClass == .compact transition.updateFrame(node: additionalActionsNode, frame: CGRect(origin: CGPoint(), size: additionalActionsSize)) contentSize.height += 8.0 } let bounds = CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: actionsSize) transition.updateFrame(node: self.shadowNode, frame: bounds.insetBy(dx: -30.0, dy: -30.0)) self.shadowNode.isHidden = widthClass == .compact contentSize.width = max(contentSize.width, actionsSize.width) contentSize.height += actionsSize.height transition.updateFrame(node: self.actionsNode, frame: bounds) if let tip = self.tip { if let textSelectionTipNode = self.textSelectionTipNode, textSelectionTipNode.tip == tip { } else { if let textSelectionTipNode = self.textSelectionTipNode { self.textSelectionTipNode = nil textSelectionTipNode.removeFromSupernode() } let textSelectionTipNode = InnerTextSelectionTipContainerNode(presentationData: self.presentationData, tip: tip) let getController = self.getController textSelectionTipNode.requestDismiss = { completion in getController()?.dismiss(completion: completion) } self.textSelectionTipNode = textSelectionTipNode self.scrollNode.addSubnode(textSelectionTipNode) } } else { if let textSelectionTipNode = self.textSelectionTipNode { self.textSelectionTipNode = nil textSelectionTipNode.removeFromSupernode() } } if let textSelectionTipNode = self.textSelectionTipNode { contentSize.height += 8.0 let textSelectionTipSize = textSelectionTipNode.updateLayout(widthClass: widthClass, presentation: presentation, width: actionsSize.width, transition: transition) transition.updateFrame(node: textSelectionTipNode, frame: CGRect(origin: CGPoint(x: 0.0, y: contentSize.height), size: textSelectionTipSize)) textSelectionTipNode.setActualSize(size: textSelectionTipSize, transition: transition) contentSize.height += textSelectionTipSize.height } return contentSize } func updateSize(containerSize: CGSize, contentSize: CGSize) { self.scrollNode.view.contentSize = contentSize self.scrollNode.frame = CGRect(origin: CGPoint(), size: containerSize) } func actionNode(at point: CGPoint) -> ContextActionNodeProtocol? { return self.actionsNode.actionNode(at: self.view.convert(point, to: self.actionsNode.view)) } func updateTheme(presentationData: PresentationData) { self.actionsNode.updateTheme(presentationData: presentationData) self.textSelectionTipNode?.updateTheme(presentationData: presentationData) } func animateIn() { self.textSelectionTipNode?.animateIn() } func animateOut(offset: CGFloat, transition: ContainedViewLayoutTransition) { guard let additionalActionsNode = self.additionalActionsNode, let additionalShadowNode = self.additionalShadowNode else { return } transition.animatePosition(node: additionalActionsNode, to: CGPoint(x: 0.0, y: offset / 2.0), additive: true) transition.animatePosition(node: additionalShadowNode, to: CGPoint(x: 0.0, y: offset / 2.0), additive: true) additionalActionsNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) additionalShadowNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) additionalActionsNode.layer.animateScale(from: 1.0, to: 0.75, duration: 0.15, removeOnCompletion: false) additionalShadowNode.layer.animateScale(from: 1.0, to: 0.75, duration: 0.15, removeOnCompletion: false) } }