// 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 var targetFrame: CGRect = .zero private let cellViewModel: MessageViewModel private let actions: [Action] private let dismiss: () -> Void // MARK: - UI public override var preferredStatusBarStyle: UIStatusBarStyle { return ThemeManager.currentTheme.statusBarStyle } private lazy var blurView: UIVisualEffectView = UIVisualEffectView() private lazy var emojiBar: UIView = { let result: UIView = UIView() result.themeShadowColor = .black result.layer.shadowOffset = CGSize.zero result.layer.shadowOpacity = 0.4 result.layer.shadowRadius = 4 result.set(.height, to: ContextMenuVC.actionViewHeight) result.alpha = 0 return result }() private lazy var emojiPlusButton: EmojiPlusButton = { let result: EmojiPlusButton = EmojiPlusButton( action: self.actions.first(where: { $0.isEmojiPlus }), dismiss: snDismiss ) result.clipsToBounds = true result.set(.width, to: EmojiPlusButton.size) result.set(.height, to: EmojiPlusButton.size) result.layer.cornerRadius = (EmojiPlusButton.size / 2) return result }() private lazy var menuView: UIView = { let result: UIView = UIView() result.themeShadowColor = .black result.layer.shadowOffset = CGSize.zero result.layer.shadowOpacity = 0.4 result.layer.shadowRadius = 4 result.alpha = 0 return result }() private lazy var timestampLabel: UILabel = { let result: UILabel = UILabel() result.font = .systemFont(ofSize: Values.verySmallFontSize) result.text = cellViewModel.dateForUI.formattedForDisplay result.themeTextColor = .textPrimary result.alpha = 0 return result }() private lazy var fallbackTimestampLabel: UILabel = { let result: UILabel = UILabel() result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) result.font = .systemFont(ofSize: Values.verySmallFontSize) result.text = cellViewModel.dateForUI.formattedForDisplay result.themeTextColor = .textPrimary result.alpha = 0 result.numberOfLines = 2 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.themeBackgroundColor = .clear // Blur view.addSubview(blurView) blurView.pin(to: view) // Snapshot snapshot.themeShadowColor = .black snapshot.layer.shadowOffset = CGSize.zero snapshot.layer.shadowOpacity = 0.4 snapshot.layer.shadowRadius = 4 view.addSubview(snapshot) // Emoji reacts let emojiBarBackgroundView: UIView = UIView() emojiBarBackgroundView.clipsToBounds = true emojiBarBackgroundView.themeBackgroundColor = .reactions_contextBackground emojiBarBackgroundView.layer.cornerRadius = (ContextMenuVC.actionViewHeight / 2) 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 = UIView() menuBackgroundView.clipsToBounds = true menuBackgroundView.themeBackgroundColor = .contextMenu_background menuBackgroundView.layer.cornerRadius = ContextMenuVC.menuCornerRadius 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 menuBackgroundView.addSubview(menuStackView) menuStackView.pin(to: menuBackgroundView) view.addSubview(menuView) // 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) } view.addSubview(fallbackTimestampLabel) fallbackTimestampLabel.pin(.top, to: .top, of: menuView) fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight) if cellViewModel.variant == .standardOutgoing { fallbackTimestampLabel.textAlignment = .right fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing) fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing) } else { fallbackTimestampLabel.textAlignment = .left fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing) fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing) } // Constrains let timestampSize: CGSize = timestampLabel.sizeThatFits(UIScreen.main.bounds.size) let menuHeight: CGFloat = CGFloat(menuStackView.arrangedSubviews.count) * ContextMenuVC.actionViewHeight let spacing: CGFloat = Values.smallSpacing self.targetFrame = calculateFrame(menuHeight: menuHeight, spacing: spacing) // Decide which timestamp label should be used based on whether it'll go off screen self.timestampLabel.isHidden = { switch cellViewModel.variant { case .standardOutgoing: return ((self.targetFrame.minX - timestampSize.width - Values.mediumSpacing) < 0) default: return ((self.targetFrame.maxX + timestampSize.width + Values.mediumSpacing) > UIScreen.main.bounds.width) } }() self.fallbackTimestampLabel.isHidden = !self.timestampLabel.isHidden // Position the snapshot view in it's original message position snapshot.frame = self.frame emojiBar.pin(.bottom, to: .top, of: view, withInset: targetFrame.minY - spacing) menuView.pin(.top, to: .top, of: view, withInset: targetFrame.maxY + spacing) switch cellViewModel.variant { case .standardOutgoing: menuView.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) emojiBar.pin(.right, to: .right, of: view, withInset: -(UIScreen.main.bounds.width - targetFrame.maxX)) case .standardIncoming, .standardIncomingDeleted: menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX) default: // Should generally only be the 'delete' action menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX) } // Tap gesture let mainTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap)) view.addGestureRecognizer(mainTapGestureRecognizer) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Fade the menus in and animate the snapshot from it's starting position to where it // needs to be on screen in order to fit the menu let view: UIView = self.view let targetFrame: CGRect = self.targetFrame UIView.animate(withDuration: 0.3) { [weak self] in self?.blurView.effect = UIBlurEffect( style: (ThemeManager.currentTheme.interfaceStyle == .light ? .light : .dark ) ) } UIView.animate(withDuration: 0.2) { [weak self] in self?.emojiBar.alpha = 1 self?.menuView.alpha = 1 self?.timestampLabel.alpha = 1 self?.fallbackTimestampLabel.alpha = 1 } UIView.animate( withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.6, options: .curveEaseInOut, animations: { [weak self] in self?.snapshot.pin(.left, to: .left, of: view, withInset: targetFrame.origin.x) self?.snapshot.pin(.top, to: .top, of: view, withInset: targetFrame.origin.y) self?.snapshot.set(.width, to: targetFrame.width) self?.snapshot.set(.height, to: targetFrame.height) self?.snapshot.superview?.setNeedsLayout() self?.snapshot.superview?.layoutIfNeeded() }, completion: nil ) // Change the blur effect on theme change ThemeManager.onThemeChange(observer: blurView) { [weak self] theme, _ in switch theme.interfaceStyle { case .light: self?.blurView.effect = UIBlurEffect(style: .light) default: self?.blurView.effect = UIBlurEffect(style: .dark) } } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) snDismiss() } 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 ?? 0), Values.mediumSpacing) let bottomMargin = max((UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0), Values.mediumSpacing) let diffY = finalFrame.height + menuHeight + Self.actionViewHeight + 2 * spacing + topMargin + bottomMargin - UIScreen.main.bounds.height if diffY > 0 { // The screenshot needs to be shrinked. Menu + emoji bar + screenshot will fill the entire screen. 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 { // The screenshot does NOT need to be shrinked. if finalFrame.origin.y - Self.actionViewHeight - spacing < topMargin { // Needs to move down finalFrame.origin.y = topMargin + Self.actionViewHeight + spacing } if finalFrame.origin.y + finalFrame.size.height + spacing + menuHeight + bottomMargin > UIScreen.main.bounds.height { // Needs to move up finalFrame.origin.y = UIScreen.main.bounds.height - bottomMargin - menuHeight - spacing - finalFrame.size.height } } 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() { let currentFrame: CGRect = self.snapshot.frame let currentLabelFrame: CGRect = self.timestampLabel.frame let originalFrame: CGRect = self.frame let frameDiff: CGRect = CGRect( x: (currentFrame.minX - originalFrame.minX), y: (currentFrame.minY - originalFrame.minY), width: (currentFrame.width - originalFrame.width), height: (currentFrame.height - originalFrame.height) ) let endLabelFrame: CGRect = CGRect( x: (currentLabelFrame.minX - (frameDiff.origin.x + frameDiff.width)), y: (currentLabelFrame.minY - (frameDiff.origin.y + frameDiff.height)), width: currentLabelFrame.width, height: currentLabelFrame.height ) // Remove the snapshot view and it's timestampLabel from the view hierarchy to remove its // constaints (and prevent them from causing animation bugs - also need to turn // 'translatesAutoresizingMaskIntoConstraints' back on so autod layout doesn't mess with // the frame manipulation) let oldSuperview: UIView? = self.snapshot.superview self.snapshot.removeFromSuperview() self.timestampLabel.removeFromSuperview() oldSuperview?.insertSubview(self.snapshot, aboveSubview: self.blurView) oldSuperview?.insertSubview(self.timestampLabel, aboveSubview: self.blurView) self.snapshot.translatesAutoresizingMaskIntoConstraints = true self.timestampLabel.translatesAutoresizingMaskIntoConstraints = true self.snapshot.frame = currentFrame self.timestampLabel.frame = currentLabelFrame UIView.animate( withDuration: 0.15, delay: 0, options: .curveEaseOut, animations: { [weak self] in self?.snapshot.frame = originalFrame self?.timestampLabel.frame = endLabelFrame }, completion: nil ) UIView.animate( withDuration: 0.25, animations: { [weak self] in self?.blurView.effect = nil self?.menuView.alpha = 0 self?.emojiBar.alpha = 0 self?.timestampLabel.alpha = 0 self?.fallbackTimestampLabel.alpha = 0 }, completion: { [weak self] _ in self?.dismiss() self?.actions.first(where: { $0.isDismissAction })?.work() } ) } }