Signal-iOS/Signal/src/ViewControllers/MessageActionsViewController.swift
2021-04-28 17:00:02 -03:00

503 lines
18 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
@objc
public class MessageAction: NSObject {
@objc
let block: (_ sender: Any?) -> Void
let accessibilityIdentifier: String
public enum MessageActionType {
case reply
case copy
case info
case delete
case share
case forward
case select
}
let actionType: MessageActionType
public init(_ actionType: MessageActionType,
accessibilityLabel: String,
accessibilityIdentifier: String,
block: @escaping (_ sender: Any?) -> Void) {
self.actionType = actionType
self.accessibilityIdentifier = accessibilityIdentifier
self.block = block
super.init()
self.accessibilityLabel = accessibilityLabel
}
var image: UIImage {
switch actionType {
case .reply:
return Theme.iconImage(.messageActionReply)
case .copy:
return Theme.iconImage(.messageActionCopy)
case .info:
return Theme.iconImage(.info)
case .delete:
return Theme.iconImage(.messageActionDelete)
case .share:
return Theme.iconImage(.messageActionShare)
case .forward:
return Theme.iconImage(.messageActionForward)
case .select:
return Theme.iconImage(.messageActionSelect)
}
}
}
@objc
public protocol MessageActionsViewControllerDelegate: class {
func messageActionsViewControllerRequestedDismissal(_ messageActionsViewController: MessageActionsViewController, withAction: MessageAction?)
func messageActionsViewControllerRequestedDismissal(_ messageActionsViewController: MessageActionsViewController, withReaction: String, isRemoving: Bool)
func messageActionsViewController(_ messageActionsViewController: MessageActionsViewController,
shouldShowReactionPickerForInteraction: TSInteraction) -> Bool
func messageActionsViewControllerRequestedKeyboardDismissal(_ messageActionsViewController: MessageActionsViewController, focusedView: UIView)
func messageActionsViewControllerLongPressGestureRecognizer(_ messageActionsViewController: MessageActionsViewController) -> UILongPressGestureRecognizer
}
@objc
public class MessageActionsViewController: UIViewController {
private let itemViewModel: CVItemViewModelImpl
@objc
public var focusedInteraction: TSInteraction { itemViewModel.interaction }
var thread: TSThread { itemViewModel.thread }
public var reactionState: InteractionReactionState? { itemViewModel.reactionState }
let focusedView: UIView
private let actionsToolbar: MessageActionsToolbar
@objc
let bottomBar = UIView()
@objc
let backdropView = UIView()
@objc
public weak var delegate: MessageActionsViewControllerDelegate?
@objc
init(itemViewModel: CVItemViewModelImpl, focusedView: UIView, actions: [MessageAction]) {
self.itemViewModel = itemViewModel
self.focusedView = focusedView
self.actionsToolbar = MessageActionsToolbar(actions: actions)
super.init(nibName: nil, bundle: nil)
self.actionsToolbar.actionDelegate = self
NotificationCenter.default.addObserver(self, selector: #selector(keyboardFrameWillChange), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func loadView() {
view = UIView()
backdropView.backgroundColor = Theme.backdropColor
view.addSubview(backdropView)
backdropView.autoPinEdgesToSuperviewEdges()
bottomBar.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_white
view.addSubview(bottomBar)
bottomBar.autoPinWidthToSuperview()
bottomBar.addSubview(actionsToolbar)
actionsToolbar.autoPinWidthToSuperview()
actionsToolbar.autoPinEdge(toSuperviewEdge: .top)
actionsToolbar.autoPinEdge(toSuperviewSafeArea: .bottom)
backdropView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapBackdrop)))
}
private var snapshotFocusedView: UIView?
private func addSnapshotFocusedView() {
snapshotFocusedView?.removeFromSuperview()
snapshotFocusedView = nil
guard let snapshotView = focusedView.snapshotView(afterScreenUpdates: true) else {
return owsFailDebug("snapshotView was unexpectedly nil")
}
view.insertSubview(snapshotView, belowSubview: bottomBar)
guard let focusedViewSuperview = focusedView.superview else {
return owsFailDebug("focusedViewSuperview was unexpectedly nil")
}
let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
snapshotView.frame = convertedFrame
snapshotView.isUserInteractionEnabled = false
snapshotFocusedView = snapshotView
}
func updateSnapshotPosition() {
guard let snapshotFocusedView = snapshotFocusedView else { return }
guard let focusedViewSuperview = focusedView.superview else {
return owsFailDebug("focusedViewSuperview was unexpectedly nil")
}
let convertedFrame = view.convert(focusedView.frame, from: focusedViewSuperview)
snapshotFocusedView.frame = convertedFrame
}
@objc func keyboardFrameWillChange(notification: Notification) {
guard let userInfo = notification.userInfo,
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve) else {
return owsFailDebug("keyboard notification missing expected userInfo properties")
}
// If the keyboard frame changes (likely due to a first responder change)
// we need to make sure the snapshot of the latest message updates its
// position and stays on top of the original message. We want this to
// animate alongside the keyboard animation, so we use old-school UIView
// animation route in order to set the appropriate curve.
UIView.beginAnimations("messageActionKeyboardStateChange", context: nil)
UIView.setAnimationBeginsFromCurrentState(true)
UIView.setAnimationCurve(animationCurve)
UIView.setAnimationDuration(animationDuration)
updateSnapshotPosition()
UIView.commitAnimations()
}
@objc func didTapBackdrop() {
delegate?.messageActionsViewControllerRequestedDismissal(self, withAction: nil)
}
@objc(presentOnWindow:prepareConstraints:animateAlongside:completion:)
func present(
on window: UIWindow,
prepareConstraints: () -> Void,
animateAlongside: (() -> Void)?,
completion: (() -> Void)?
) {
guard view.superview == nil else {
return owsFailDebug("trying to dismiss when already presented")
}
ImpactHapticFeedback.impactOccured(style: .light)
window.addSubview(view)
prepareConstraints()
backdropView.alpha = 0
bottomBar.alpha = 0
window.layoutIfNeeded()
addSnapshotFocusedView()
addReactionPickerIfNecessary()
quickReactionPicker?.playPresentationAnimation(duration: 0.2)
UIView.animate(withDuration: 0.2, animations: {
self.backdropView.alpha = 1
self.bottomBar.alpha = 1
animateAlongside?()
}) { _ in
completion?()
}
}
@objc
func dismissWithoutAnimating() {
AssertIsOnMainThread()
guard view.superview != nil else {
return owsFailDebug("trying to dismiss when not presented")
}
view.removeFromSuperview()
anyReactionPicker?.dismiss(animated: false)
}
@objc(dismissAndAnimateAlongside:completion:)
func dismiss(animateAlongside: (() -> Void)?, completion: (() -> Void)?) {
guard view.superview != nil else {
return owsFailDebug("trying to dismiss when not presented")
}
var alreadyRanCompletion = false
let completeOnce = {
guard !alreadyRanCompletion else { return }
AssertIsOnMainThread()
alreadyRanCompletion = true
self.view.removeFromSuperview()
completion?()
}
anyReactionPicker?.dismiss(animated: true, completion: completeOnce)
quickReactionPicker?.playDismissalAnimation(duration: 0.2, completion: completeOnce)
UIView.animate(withDuration: 0.2,
animations: {
self.backdropView.alpha = 0
self.bottomBar.alpha = 0
self.snapshotFocusedView?.alpha = 0
animateAlongside?()
}, completion: { _ in completeOnce() })
}
// MARK: - Reaction handling
var canAddReact: Bool {
guard thread.canSendToThread else { return false }
guard let delegate = delegate else { return false }
return delegate.messageActionsViewController(self, shouldShowReactionPickerForInteraction: focusedInteraction)
}
private var quickReactionPicker: MessageReactionPicker?
private func addReactionPickerIfNecessary() {
guard canAddReact, quickReactionPicker == nil else { return }
let picker = MessageReactionPicker(selectedEmoji: reactionState?.localUserEmoji, delegate: self)
view.addSubview(picker)
view.setNeedsLayout()
view.layoutIfNeeded()
// The position of the picker is calculated relative to the
// starting touch point of the presenting gesture.
var pickerOrigin = initialTouchLocation.minus(CGPoint(x: picker.width / 2, y: picker.height / 2))
// The picker always starts 25pts above the touch point
pickerOrigin.y -= 25 + picker.height
// If the picker is not at least 16pts away from the edge
// of the screen, we offset it so that it is.
let edgeThresholds = backdropView.safeAreaInsets.plus(16)
if pickerOrigin.x < backdropView.frame.origin.x + edgeThresholds.left {
pickerOrigin.x = backdropView.frame.origin.x + edgeThresholds.left
} else if pickerOrigin.x > backdropView.frame.maxX - edgeThresholds.right - picker.width {
pickerOrigin.x = backdropView.frame.maxX - edgeThresholds.right - picker.width
}
if pickerOrigin.y < backdropView.frame.origin.y + edgeThresholds.top {
pickerOrigin.y = backdropView.frame.origin.y + edgeThresholds.top
} else if pickerOrigin.y > backdropView.frame.maxY - edgeThresholds.bottom - picker.height {
pickerOrigin.y = backdropView.frame.maxY - edgeThresholds.bottom - picker.height
}
picker.autoPinEdge(.leading, to: .leading, of: view, withOffset: pickerOrigin.x)
picker.autoPinEdge(.top, to: .top, of: view, withOffset: pickerOrigin.y)
quickReactionPicker = picker
}
private lazy var initialTouchLocation = currentTouchLocation
private var currentTouchLocation: CGPoint {
guard let delegate = delegate else {
owsFailDebug("unexpectedly missing delegate")
return view.center
}
return delegate.messageActionsViewControllerLongPressGestureRecognizer(self).location(in: view)
}
private var gestureExitedDeadZone = false
private let deadZoneRadius: CGFloat = 30
@objc
func didChangeLongpress() {
// Do nothing if reactions aren't enabled.
guard canAddReact else { return }
guard let reactionPicker = quickReactionPicker else {
return owsFailDebug("unexpectedly missing reaction picker")
}
// Only start gesture based interactions once the touch
// has moved out of the dead zone.
if !gestureExitedDeadZone {
let distanceFromInitialLocation = abs(hypot(
currentTouchLocation.x - initialTouchLocation.x,
currentTouchLocation.y - initialTouchLocation.y
))
gestureExitedDeadZone = distanceFromInitialLocation >= deadZoneRadius
if !gestureExitedDeadZone { return }
}
reactionPicker.updateFocusPosition(reactionPicker.convert(currentTouchLocation, from: view), animated: true)
}
@objc
func didEndLongpress() {
// If the long press never moved, do nothing when we release.
// The menu should continue to display until the user dismisses.
guard gestureExitedDeadZone else { return }
// If there's not a focused reaction, dismiss the menu with no action
guard let focusedEmoji = quickReactionPicker?.focusedEmoji else {
delegate?.messageActionsViewControllerRequestedDismissal(self, withAction: nil)
return
}
if focusedEmoji == MessageReactionPicker.anyEmojiName {
showAnyEmojiPicker()
} else {
// Otherwise, dismiss the menu and send the focused emoji
delegate?.messageActionsViewControllerRequestedDismissal(
self,
withReaction: focusedEmoji,
isRemoving: focusedEmoji == reactionState?.localUserEmoji
)
}
}
private var anyReactionPicker: EmojiPickerSheet?
func showAnyEmojiPicker() {
let picker = EmojiPickerSheet { [weak self] emoji in
guard let self = self else { return }
guard let emojiString = emoji?.rawValue else {
self.delegate?.messageActionsViewControllerRequestedDismissal(self, withAction: nil)
return
}
self.delegate?.messageActionsViewControllerRequestedDismissal(
self,
withReaction: emojiString,
isRemoving: emojiString == self.reactionState?.localUserEmoji
)
}
picker.externalBackdropView = backdropView
anyReactionPicker = picker
// Presenting the emoji picker causes the conversation view controller
// to lose first responder status. This is expected. Unfortunately, to
// do a bug with window wrangling it doesn't properly become first responder
// again when all is done. Instead, we get into a broken state half way
// between the keyboad being presented and not. This results in the user
// being unable to send messages. In order to work around this, we notify
// the CVC we're going to present so it can manually resign first responder
// status ahead of time. This allows the user to return to a good state
// after posting a custom reaction.
delegate?.messageActionsViewControllerRequestedKeyboardDismissal(self, focusedView: focusedView)
present(picker, animated: true)
quickReactionPicker?.playDismissalAnimation(duration: 0.2) {
self.quickReactionPicker?.removeFromSuperview()
self.quickReactionPicker = nil
self.bottomBar.alpha = 0
}
}
}
extension MessageActionsViewController: MessageReactionPickerDelegate {
func didSelectReaction(reaction: String, isRemoving: Bool) {
delegate?.messageActionsViewControllerRequestedDismissal(self, withReaction: reaction, isRemoving: isRemoving)
}
func didSelectAnyEmoji() {
showAnyEmojiPicker()
}
}
extension MessageActionsViewController: MessageActionsToolbarDelegate {
public func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction) {
delegate?.messageActionsViewControllerRequestedDismissal(self, withAction: executedAction)
}
}
public protocol MessageActionsToolbarDelegate: class {
func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction)
}
public class MessageActionsToolbar: UIToolbar {
weak var actionDelegate: MessageActionsToolbarDelegate?
let actions: [MessageAction]
deinit {
Logger.verbose("")
}
required init(actions: [MessageAction]) {
self.actions = actions
super.init(frame: .zero)
isTranslucent = false
isOpaque = true
autoresizingMask = .flexibleHeight
translatesAutoresizingMaskIntoConstraints = false
barTintColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_white
setShadowImage(UIImage(), forToolbarPosition: .any)
buildItems()
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: -
private var itemToAction = [UIBarButtonItem: MessageAction]()
private var actionToItem = [MessageAction: UIBarButtonItem]()
private func buildItems() {
var newItems = [UIBarButtonItem]()
for action in actions {
let actionItem = UIBarButtonItem(
image: action.image.withRenderingMode(.alwaysTemplate),
style: .plain,
target: self,
action: #selector(didTapItem(_:))
)
actionItem.tintColor = Theme.primaryIconColor
actionItem.accessibilityLabel = action.accessibilityLabel
newItems.append(actionItem)
itemToAction[actionItem] = action
actionToItem[action] = actionItem
if action != actions.last {
newItems.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
}
}
// If we only have a single button, center it.
if newItems.count == 1 {
newItems.insert(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), at: 0)
newItems.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
}
items = newItems
}
public func buttonItem(for actionType: MessageAction.MessageActionType) -> UIBarButtonItem? {
guard let action = (actions.first { $0.actionType == actionType }) else {
return nil
}
assert(actionToItem[action] != nil)
return actionToItem[action]
}
@objc func didTapItem(_ item: UIBarButtonItem) {
guard let action = itemToAction[item] else {
return owsFailDebug("missing action for item")
}
actionDelegate?.messageActionsToolbar(self, executedAction: action)
}
}