mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			414 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			414 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			Swift
		
	
| // 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()
 | |
|             }
 | |
|         )
 | |
|     }
 | |
| }
 |