Signal-iOS/Signal/src/ViewControllers/MessageActionsViewController.swift

623 lines
23 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
let contextMenuTitle: String
let contextMenuAttributes: ContextMenuAction.Attributes
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,
contextMenuTitle: String,
contextMenuAttributes: ContextMenuAction.Attributes,
block: @escaping (_ sender: Any?) -> Void) {
self.actionType = actionType
self.accessibilityIdentifier = accessibilityIdentifier
self.contextMenuTitle = contextMenuTitle
self.contextMenuAttributes = contextMenuAttributes
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:
if FeatureFlags.contextMenus {
return Theme.iconImage(.contextMenuInfo)
} else {
return Theme.iconImage(.info)
}
case .delete:
return Theme.iconImage(.messageActionDelete)
case .share:
return Theme.iconImage(.messageActionShare)
case .forward:
return Theme.iconImage(.messageActionForward)
case .select:
if FeatureFlags.contextMenus {
return Theme.iconImage(.contextMenuSelect)
} else {
return Theme.iconImage(.messageActionSelect)
}
}
}
}
@objc
public protocol MessageActionsViewControllerDelegate: AnyObject {
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
let toolbarMode = MessageActionsToolbar.Mode.normal(messagesActions: actions)
self.actionsToolbar = MessageActionsToolbar(mode: toolbarMode)
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.canSendReactionToThread 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 var messageActionsToolbarSelectedInteractionCount: Int {
0
}
}
public protocol MessageActionsToolbarDelegate: AnyObject {
func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction)
var messageActionsToolbarSelectedInteractionCount: Int { get }
}
public class MessageActionsToolbar: UIToolbar {
weak var actionDelegate: MessageActionsToolbarDelegate?
enum Mode {
case normal(messagesActions: [MessageAction])
case selection(deleteMessagesAction: MessageAction,
forwardMessagesAction: MessageAction)
}
private let mode: Mode
deinit {
Logger.verbose("")
}
required init(mode: Mode) {
self.mode = mode
super.init(frame: .zero)
isTranslucent = false
isOpaque = true
autoresizingMask = .flexibleHeight
translatesAutoresizingMaskIntoConstraints = false
setShadowImage(UIImage(), forToolbarPosition: .any)
buildItems()
NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .ThemeDidChange, object: nil)
applyTheme()
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: -
@objc
private func applyTheme() {
AssertIsOnMainThread()
barTintColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_white
buildItems()
}
public func updateContent() {
buildItems()
}
private func buildItems() {
switch mode {
case .normal(let messagesActions):
buildNormalItems(messagesActions: messagesActions)
case .selection(let deleteMessagesAction, let forwardMessagesAction):
buildSelectionItems(deleteMessagesAction: deleteMessagesAction,
forwardMessagesAction: forwardMessagesAction)
}
}
var actionItems = [MessageActionsToolbarButton]()
private func buildNormalItems(messagesActions: [MessageAction]) {
var newItems = [UIBarButtonItem]()
var actionItems = [MessageActionsToolbarButton]()
for action in messagesActions {
if !newItems.isEmpty {
newItems.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
}
let actionItem = MessageActionsToolbarButton(actionsToolbar: self, messageAction: action)
actionItem.tintColor = Theme.primaryIconColor
actionItem.accessibilityLabel = action.accessibilityLabel
newItems.append(actionItem)
actionItems.append(actionItem)
}
// 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
self.actionItems = actionItems
}
private func buildSelectionItems(deleteMessagesAction: MessageAction,
forwardMessagesAction: MessageAction) {
let deleteItem = MessageActionsToolbarButton(actionsToolbar: self, messageAction: deleteMessagesAction)
let forwardItem = MessageActionsToolbarButton(actionsToolbar: self, messageAction: forwardMessagesAction)
let selectedCount: Int = actionDelegate?.messageActionsToolbarSelectedInteractionCount ?? 0
let labelTitle: String
if selectedCount == 0 {
labelTitle = NSLocalizedString("MESSAGE_ACTIONS_TOOLBAR_LABEL_0",
comment: "Label for the toolbar used in the multi-select mode of conversation view when 0 items are selected.")
} else if selectedCount == 1 {
labelTitle = NSLocalizedString("MESSAGE_ACTIONS_TOOLBAR_LABEL_1",
comment: "Label for the toolbar used in the multi-select mode of conversation view when 1 item is selected.")
} else {
let labelFormat = NSLocalizedString("MESSAGE_ACTIONS_TOOLBAR_LABEL_N_FORMAT",
comment: "Format for the toolbar used in the multi-select mode of conversation view. Embeds: {{ %@ the number of currently selected items }}.")
labelTitle = String(format: labelFormat, OWSFormat.formatInt(selectedCount))
}
let label = UILabel()
label.text = labelTitle
label.font = UIFont.ows_dynamicTypeBodyClamped
label.textColor = Theme.primaryTextColor
label.sizeToFit()
let labelItem = UIBarButtonItem(customView: label)
var newItems = [UIBarButtonItem]()
newItems.append(deleteItem)
newItems.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
newItems.append(labelItem)
newItems.append(UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil))
newItems.append(forwardItem)
items = newItems
self.actionItems = [ deleteItem, forwardItem ]
}
public func buttonItem(for actionType: MessageAction.MessageActionType) -> UIBarButtonItem? {
for actionItem in actionItems {
if let messageAction = actionItem.messageAction,
messageAction.actionType == actionType {
return actionItem
}
}
owsFailDebug("Missing action item: \(actionType).")
return nil
}
}
// MARK: -
class MessageActionsToolbarButton: UIBarButtonItem {
private weak var actionsToolbar: MessageActionsToolbar?
fileprivate var messageAction: MessageAction?
required override init() {
super.init()
}
required init(actionsToolbar: MessageActionsToolbar, messageAction: MessageAction) {
self.actionsToolbar = actionsToolbar
self.messageAction = messageAction
super.init()
self.image = messageAction.image.withRenderingMode(.alwaysTemplate)
self.style = .plain
self.target = self
self.action = #selector(didTapItem(_:))
self.tintColor = Theme.primaryIconColor
self.accessibilityLabel = messageAction.accessibilityLabel
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc
private func didTapItem(_ item: UIBarButtonItem) {
AssertIsOnMainThread()
guard let messageAction = messageAction,
let actionsToolbar = actionsToolbar,
let actionDelegate = actionsToolbar.actionDelegate else {
return
}
actionDelegate.messageActionsToolbar(actionsToolbar, executedAction: messageAction)
}
}