// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import SessionUIKit import SessionMessagingKit final class ContextMenuVC: UIViewController { private static let actionViewHeight: CGFloat = 40 private static let menuCornerRadius: CGFloat = 8 private let snapshot: UIView private let frame: CGRect private let cellViewModel: MessageViewModel private let actions: [Action] private let dismiss: () -> Void // MARK: - UI private lazy var blurView: UIVisualEffectView = UIVisualEffectView(effect: nil) private lazy var emojiBar: UIView = { let result = UIView() result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowOffset = CGSize.zero result.layer.shadowOpacity = 0.4 result.layer.shadowRadius = 4 result.set(.height, to: ContextMenuVC.actionViewHeight) return result }() private lazy var emojiPlusButton: EmojiPlusButton = { let result = EmojiPlusButton( action: self.actions.first(where: { $0.isEmojiPlus }), dismiss: snDismiss ) result.set(.width, to: EmojiPlusButton.size) result.set(.height, to: EmojiPlusButton.size) result.layer.cornerRadius = EmojiPlusButton.size / 2 result.layer.masksToBounds = true return result }() private lazy var menuView: UIView = { let result: UIView = UIView() result.layer.shadowColor = UIColor.black.cgColor result.layer.shadowOffset = CGSize.zero result.layer.shadowOpacity = 0.4 result.layer.shadowRadius = 4 return result }() private lazy var timestampLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.verySmallFontSize) result.textColor = (isLightMode ? .black : .white) if let dateForUI: Date = cellViewModel.dateForUI { result.text = dateForUI.formattedForDisplay } return result }() // MARK: - Initialization init( snapshot: UIView, frame: CGRect, cellViewModel: MessageViewModel, actions: [Action], dismiss: @escaping () -> Void ) { self.snapshot = snapshot self.frame = frame self.cellViewModel = cellViewModel self.actions = actions self.dismiss = dismiss super.init(nibName: nil, bundle: nil) } override init(nibName: String?, bundle: Bundle?) { preconditionFailure("Use init(snapshot:) instead.") } required init?(coder: NSCoder) { preconditionFailure("Use init(coder:) instead.") } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() // Background color view.backgroundColor = .clear // Blur view.addSubview(blurView) blurView.pin(to: view) // Snapshot snapshot.layer.shadowColor = UIColor.black.cgColor snapshot.layer.shadowOffset = CGSize.zero snapshot.layer.shadowOpacity = 0.4 snapshot.layer.shadowRadius = 4 view.addSubview(snapshot) // Timestamp view.addSubview(timestampLabel) timestampLabel.center(.vertical, in: snapshot) if cellViewModel.variant == .standardOutgoing { timestampLabel.pin(.right, to: .left, of: snapshot, withInset: -Values.smallSpacing) } else { timestampLabel.pin(.left, to: .right, of: snapshot, withInset: Values.smallSpacing) } // Emoji reacts let emojiBarBackgroundView = UIView() emojiBarBackgroundView.backgroundColor = Colors.receivedMessageBackground emojiBarBackgroundView.layer.cornerRadius = ContextMenuVC.actionViewHeight / 2 emojiBarBackgroundView.layer.masksToBounds = true emojiBar.addSubview(emojiBarBackgroundView) emojiBarBackgroundView.pin(to: emojiBar) emojiBar.addSubview(emojiPlusButton) emojiPlusButton.pin(.right, to: .right, of: emojiBar, withInset: -Values.smallSpacing) emojiPlusButton.center(.vertical, in: emojiBar) let emojiBarStackView = UIStackView( arrangedSubviews: actions .filter { $0.isEmojiAction } .map { action -> EmojiReactsView in EmojiReactsView(for: action, dismiss: snDismiss) } ) emojiBarStackView.axis = .horizontal emojiBarStackView.spacing = Values.smallSpacing emojiBarStackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing) emojiBarStackView.isLayoutMarginsRelativeArrangement = true emojiBar.addSubview(emojiBarStackView) emojiBarStackView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: emojiBar) emojiBarStackView.pin(.right, to: .left, of: emojiPlusButton) // Hide the emoji bar if we have no emoji actions emojiBar.isHidden = emojiBarStackView.arrangedSubviews.isEmpty view.addSubview(emojiBar) // Menu let menuBackgroundView = UIView() menuBackgroundView.backgroundColor = Colors.receivedMessageBackground menuBackgroundView.layer.cornerRadius = ContextMenuVC.menuCornerRadius menuBackgroundView.layer.masksToBounds = true menuView.addSubview(menuBackgroundView) menuBackgroundView.pin(to: menuView) let menuStackView = UIStackView( arrangedSubviews: actions .filter { !$0.isEmojiAction && !$0.isEmojiPlus && !$0.isDismissAction } .map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) } ) menuStackView.axis = .vertical menuView.addSubview(menuStackView) menuStackView.pin(to: menuView) view.addSubview(menuView) // Constrains let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight let spacing: CGFloat = Values.smallSpacing let targetFrame: CGRect = calculateFrame(menuHeight: menuHeight, spacing: spacing) snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x) snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y) snapshot.set(.width, to: frame.width) snapshot.set(.height, to: frame.height) emojiBar.pin(.bottom, to: .top, of: snapshot, withInset: -spacing) menuView.pin(.top, to: .bottom, of: snapshot, withInset: spacing) switch cellViewModel.variant { case .standardOutgoing: menuView.pin(.right, to: .right, of: snapshot) emojiBar.pin(.right, to: .right, of: snapshot) case .standardIncoming: menuView.pin(.left, to: .left, of: snapshot) emojiBar.pin(.left, to: .left, of: snapshot) default: break // Should never occur } // Tap gesture let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) view.addGestureRecognizer(mainTapGestureRecognizer) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) UIView.animate(withDuration: 0.25) { self.blurView.effect = UIBlurEffect(style: .regular) self.menuView.alpha = 1 } } func calculateFrame(menuHeight: CGFloat, spacing: CGFloat) -> CGRect { var finalFrame: CGRect = frame let ratio: CGFloat = (frame.width / frame.height) // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement) let topMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.top, Values.mediumSpacing) let bottomMargin = max(UIApplication.shared.keyWindow!.safeAreaInsets.bottom, Values.mediumSpacing) let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height if diffY > 0 { finalFrame.size.height -= diffY let newWidth = ratio * finalFrame.size.height if cellViewModel.variant == .standardOutgoing { finalFrame.origin.x += finalFrame.size.width - newWidth } finalFrame.size.width = newWidth finalFrame.origin.y = UIScreen.main.bounds.height - finalFrame.size.height - menuHeight - bottomMargin - spacing } else { finalFrame.origin.y = (UIScreen.main.bounds.height - finalFrame.size.height) / 2 } return finalFrame } // MARK: - Layout override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() menuView.layer.shadowPath = UIBezierPath( roundedRect: menuView.bounds, cornerRadius: ContextMenuVC.menuCornerRadius ).cgPath emojiBar.layer.shadowPath = UIBezierPath( roundedRect: emojiBar.bounds, cornerRadius: (ContextMenuVC.actionViewHeight / 2) ).cgPath } // MARK: - Interaction @objc private func handleTap() { snDismiss() } func snDismiss() { UIView.animate( withDuration: 0.25, animations: { [weak self] in self?.blurView.effect = nil self?.menuView.alpha = 0 self?.emojiBar.alpha = 0 self?.snapshot.alpha = 0 self?.timestampLabel.alpha = 0 }, completion: { [weak self] _ in self?.dismiss() self?.actions.first(where: { $0.isDismissAction })?.work() } ) } }