Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/ContextMenus/CustomContextMenus/ContextMenuInteraction.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
public import UIKit

/// UIContextMenuInteractionDelegate analog
public protocol ContextMenuInteractionDelegate: AnyObject {
    func contextMenuInteraction(
        _ interaction: ContextMenuInteraction,
        configurationForMenuAtLocation location: CGPoint,
    ) -> ContextMenuConfiguration?
    func contextMenuInteraction(
        _ interaction: ContextMenuInteraction,
        previewForHighlightingMenuWithConfiguration configuration: ContextMenuConfiguration,
    ) -> ContextMenuTargetedPreview?
    func contextMenuInteraction(
        _ interaction: ContextMenuInteraction,
        willDisplayMenuForConfiguration: ContextMenuConfiguration,
    )
    func contextMenuInteraction(
        _ interaction: ContextMenuInteraction,
        willEndForConfiguration: ContextMenuConfiguration,
    )
    func contextMenuInteraction(
        _ interaction: ContextMenuInteraction,
        didEndForConfiguration: ContextMenuConfiguration,
    )

}

/// UIContextMenuInteraction analog
public class ContextMenuInteraction: NSObject, UIInteraction {

    weak var delegate: ContextMenuInteractionDelegate?
    fileprivate var contextMenuController: ContextMenuController?

    private let sourceViewBounceDuration = 0.2
    fileprivate var gestureEligibleForMenuPresentation: Bool {
        didSet {
            if !gestureEligibleForMenuPresentation {
                // Animate back out
                UIView.animate(
                    withDuration: sourceViewBounceDuration,
                    delay: 0,
                    options: [.curveEaseInOut, .beginFromCurrentState],
                    animations: {
                        self.targetedPreview?.view.transform = CGAffineTransform.identity
                    },
                    completion: nil,
                )
            }
        }
    }

    fileprivate var locationInView = CGPoint.zero
    fileprivate var configuration: ContextMenuConfiguration?
    fileprivate var targetedPreview: ContextMenuTargetedPreview?

    private lazy var longPressGestureRecognizer: UIGestureRecognizer = {
        let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(longPressRecognized(sender:)))
        recognizer.minimumPressDuration = 0.2
        return recognizer
    }()

    // MARK: UIInteraction

    public var view: UIView?

    public func willMove(to view: UIView?) {
        if view != self.view {
            self.view?.removeGestureRecognizer(longPressGestureRecognizer)
        }
    }

    public func didMove(to view: UIView?) {
        if view != self.view {
            self.view = view
            self.view?.addGestureRecognizer(longPressGestureRecognizer)
        }
    }

    public init(
        delegate: ContextMenuInteractionDelegate,
    ) {
        self.delegate = delegate
        gestureEligibleForMenuPresentation = false
        super.init()
    }

    public func initiateContextMenuGesture(locationInView: CGPoint, presentImmediately: Bool) {
        self.locationInView = locationInView
        gestureEligibleForMenuPresentation = true

        guard let delegate = self.delegate else {
            owsFailDebug("Missing ContextMenuInteractionDelegate")
            return
        }

        guard let view = self.view else {
            owsFailDebug("Missing view")
            return
        }

        guard let contextMenuConfiguration = delegate.contextMenuInteraction(self, configurationForMenuAtLocation: locationInView) else {
            return
        }

        configuration = contextMenuConfiguration

        guard let targetedPreview = delegate.contextMenuInteraction(self, previewForHighlightingMenuWithConfiguration: contextMenuConfiguration) ?? ContextMenuTargetedPreview(view: view, alignment: .center, accessoryViews: nil) else { return }

        for accessory in targetedPreview.accessoryViews {
            accessory.delegate = self
        }

        self.targetedPreview = targetedPreview

        if presentImmediately {
            self.presentMenu(locationInView: self.locationInView, presentImmediately: true)
        } else {
            UIView.animate(
                withDuration: sourceViewBounceDuration,
                delay: 0,
                options: [.curveEaseInOut, .beginFromCurrentState],
                animations: {
                    targetedPreview.view.transform = .scale(0.95)
                },
                completion: { finished in
                    let shouldPresent = finished && self.gestureEligibleForMenuPresentation

                    if shouldPresent {
                        self.presentMenu(locationInView: self.locationInView, presentImmediately: false)
                        // Animate back out
                        self.gestureEligibleForMenuPresentation = false
                    }
                },
            )
        }
    }

    public func presentMenu(locationInView: CGPoint, presentImmediately: Bool) {
        guard let view = self.view else {
            owsFailDebug("Missing view")
            return
        }

        guard let window = view.window else {
            owsFailDebug("View must be in a window!")
            return
        }

        guard let configuration = self.configuration else {
            owsFailDebug("Missing context menu configuration")
            return
        }

        guard let targetedPreview = self.targetedPreview else {
            owsFailDebug("Missing targeted preview")
            return
        }

        presentMenu(window: window, contextMenuConfiguration: configuration, targetedPreview: targetedPreview, presentImmediately: presentImmediately)
    }

    public func presentMenu(window: UIWindow, contextMenuConfiguration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview, presentImmediately: Bool) {

        let menuAccessory = menuAccessory(configuration: contextMenuConfiguration, targetedPreview: targetedPreview)
        let contextMenuController = ContextMenuController(configuration: contextMenuConfiguration, preview: targetedPreview, initiatingGestureRecognizer: initiatingGestureRecognizer(), menuAccessory: menuAccessory, presentImmediately: presentImmediately)
        contextMenuController.delegate = self
        self.contextMenuController = contextMenuController

        delegate?.contextMenuInteraction(self, willDisplayMenuForConfiguration: contextMenuConfiguration)
        ImpactHapticFeedback.impactOccurred(style: .medium, intensity: 0.8)

        window.addSubview(contextMenuController.view)
        contextMenuController.view.frame = window.bounds
    }

    public func initiatingGestureRecognizer() -> UIGestureRecognizer? {
        return longPressGestureRecognizer
    }

    public func menuAccessory(configuration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview) -> ContextMenuActionsAccessory {

        var alignments: [(ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Edge, ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Origin)] = [(.bottom, .exterior)]

        switch targetedPreview.alignment {
        case .left:
            alignments.append((.leading, .interior))
        case .right:
            alignments.append((.trailing, .interior))
        case .center:
            break
        }

        let menu = configuration.actionProvider?([]) ?? ContextMenu([])
        let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: alignments, alignmentOffset: targetedPreview.alignmentOffset ?? CGPoint(x: 0, y: 12))
        let accessory = ContextMenuActionsAccessory(menu: menu, accessoryAlignment: alignment, forceDarkTheme: configuration.forceDarkTheme)
        accessory.delegate = self
        return accessory
    }

    public func dismissMenu(animated: Bool, completion: @escaping () -> Void) {
        guard let configuration = self.configuration else {
            return
        }

        delegate?.contextMenuInteraction(self, willEndForConfiguration: configuration)

        if animated {
            contextMenuController?.animateOut({
                self.delegate?.contextMenuInteraction(self, didEndForConfiguration: configuration)
                self.contextMenuController?.view.removeFromSuperview()
                self.contextMenuController = nil
                completion()
            })
        } else {
            targetedPreview?.view.isHidden = false
            targetedPreview?.auxiliaryView?.isHidden = false
            delegate?.contextMenuInteraction(self, didEndForConfiguration: configuration)
            self.contextMenuController?.view.removeFromSuperview()
            self.contextMenuController = nil
            completion()
        }
    }

    // MARK: Private

    @objc
    private func longPressRecognized(sender: UIGestureRecognizer) {
        let locationInView = sender.location(in: self.view)
        switch sender.state {
        case .began:
            initiateContextMenuGesture(locationInView: locationInView, presentImmediately: false)
        case .changed:
            contextMenuController?.gestureDidChange()
        case .ended, .cancelled:
            contextMenuController?.gestureDidEnd()
            gestureEligibleForMenuPresentation = false
        default:
            break
        }
    }
}

extension ContextMenuInteraction: ContextMenuControllerDelegate, ContextMenuTargetedPreviewAccessoryInteractionDelegate {

    func contextMenuTargetedPreviewAccessoryRequestsDismissal(_ accessory: ContextMenuTargetedPreviewAccessory, completion: @escaping () -> Void) {
        dismissMenu(animated: true, completion: completion)
    }

    func contextMenuTargetedPreviewAccessoryPreviewAlignment(_ accessory: ContextMenuTargetedPreviewAccessory) -> ContextMenuTargetedPreview.Alignment {
        return contextMenuController?.contextMenuPreview.alignment ?? .center
    }

    func contextMenuControllerRequestsDismissal(_ contextMenuController: ContextMenuController) {
        dismissMenu(animated: true, completion: { })
    }

    func contextMenuTargetedPreviewAccessoryRequestsEmojiPicker(
        for message: TSMessage,
        accessory: ContextMenuTargetedPreviewAccessory,
        completion: @escaping (String) -> Void,
    ) {
        contextMenuController?.showEmojiSheet(message: message, completion: { emojiString in
            self.contextMenuController?.dismissEmojiSheet(animated: true, completion: {
                completion(emojiString)
            })
        })
    }

}

// Custom subclass for chat history CVC interactions
public class ChatHistoryContextMenuInteraction: ContextMenuInteraction {

    public let itemViewModel: CVItemViewModelImpl
    public let thread: TSThread
    public let messageActions: [MessageAction]
    public let keyboardWasActive: Bool
    public let chatHistoryLongPressGesture: UIGestureRecognizer?
    public var contextMenuVisible: Bool {
        return contextMenuController != nil
    }

    /// Default initializer
    /// - Parameters:
    ///   - delegate: ContextMenuInteraction delegate
    ///   - itemViewModel: CVItemViewModelImpl related to context menu item
    ///   - messageActions: Message actions related to context menu item
    public init(
        delegate: ContextMenuInteractionDelegate,
        itemViewModel: CVItemViewModelImpl,
        thread: TSThread,
        messageActions: [MessageAction],
        initiatingGestureRecognizer: UIGestureRecognizer?,
        keyboardWasActive: Bool,
    ) {
        self.itemViewModel = itemViewModel
        self.thread = thread
        self.messageActions = messageActions
        self.keyboardWasActive = keyboardWasActive
        self.chatHistoryLongPressGesture = initiatingGestureRecognizer
        super.init(delegate: delegate)
    }

    override public func willMove(to view: UIView?) { }

    override public func didMove(to view: UIView?) {
        self.view = view
    }

    public func initiatingGestureRecognizerDidChange() {
        contextMenuController?.gestureDidChange()
    }

    public func initiatingGestureRecognizerDidEnd() {

        if contextMenuController == nil {
            cancelPresentationGesture()
        } else {
            contextMenuController?.gestureDidEnd()
        }

    }

    public func cancelPresentationGesture() {
        gestureEligibleForMenuPresentation = false

        if contextMenuController == nil, let configuration = self.configuration {
            delegate?.contextMenuInteraction(self, willEndForConfiguration: configuration)
            delegate?.contextMenuInteraction(self, didEndForConfiguration: configuration)
        }
    }

    override public func initiatingGestureRecognizer() -> UIGestureRecognizer? {
        return chatHistoryLongPressGesture
    }

    override public func menuAccessory(configuration: ContextMenuConfiguration, targetedPreview: ContextMenuTargetedPreview) -> ContextMenuActionsAccessory {
        let isRTL = CurrentAppContext().isRTL
        let menu = configuration.actionProvider?([]) ?? ContextMenu([])
        let isIncomingMessage = itemViewModel.interaction.interactionType == .incomingMessage
        let isMessageType = itemViewModel.interaction.interactionType == .outgoingMessage || isIncomingMessage
        let horizontalEdgeAlignment: ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Edge = isIncomingMessage ? (isRTL ? .trailing : .leading) : (isRTL ? .leading : .trailing)
        let alignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: [(.bottom, .exterior), (horizontalEdgeAlignment, .interior)], alignmentOffset: CGPoint(x: 0, y: 12))
        let accessory = ContextMenuActionsAccessory(menu: menu, accessoryAlignment: alignment)
        let landscapeAlignmentOffset = isMessageType ? CGPoint(x: 12, y: 0) : alignment.alignmentOffset
        let horizontalLandscapeEdgeAlignment: ContextMenuTargetedPreviewAccessory.AccessoryAlignment.Edge = isIncomingMessage ? (isRTL ? .leading : .trailing) : (isRTL ? .trailing : .leading)
        accessory.landscapeAccessoryAlignment = ContextMenuTargetedPreviewAccessory.AccessoryAlignment(alignments: [isMessageType ? (.top, .interior) : (.bottom, .exterior), (horizontalLandscapeEdgeAlignment, .exterior)], alignmentOffset: landscapeAlignmentOffset)
        accessory.delegate = self
        return accessory
    }
}