Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Double tap message to show context menu #5772

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions Signal/src/ViewControllers/ConversationView/CV/CVCell.swift
Expand Up @@ -334,6 +334,21 @@ public extension CVRootComponentHost {
renderItem: renderItem)
}

func findDoubleTapHandler(sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate) -> CVDoubleTapHandler? {
guard let renderItem = renderItem else {
owsFailDebug("Missing renderItem.")
return nil
}
guard let componentView = componentView else {
owsFailDebug("Missing componentView.")
return nil
}
return renderItem.rootComponent.findDoubleTapHandler(sender: sender,
componentDelegate: componentDelegate,
componentView: componentView,
renderItem: renderItem)
}

func findPanHandler(sender: UIPanGestureRecognizer,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState) -> CVPanHandler? {
Expand Down
13 changes: 13 additions & 0 deletions Signal/src/ViewControllers/ConversationView/CV/CVComponent.swift
Expand Up @@ -34,6 +34,11 @@ public protocol CVComponent: AnyObject {
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVLongPressHandler?

func findDoubleTapHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVDoubleTapHandler?

func findPanHandler(sender: UIPanGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
Expand Down Expand Up @@ -119,6 +124,14 @@ public class CVComponentBase: NSObject {
return nil
}

public func findDoubleTapHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVDoubleTapHandler? {
Logger.verbose("Ignoring double tap.")
return nil
}

public func findPanHandler(sender: UIPanGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
Expand Down
Expand Up @@ -66,6 +66,33 @@ public protocol CVComponentDelegate: AnyObject, AudioMessageViewDelegate {

func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl)

// MARK: - Double Tap

func didDoubleTapTextViewItem(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool)

func didDoubleTapMediaViewItem(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool)

func didDoubleTapQuote(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool)

func didDoubleTapSystemMessage(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl)

func didDoubleTapSticker(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool)

func didDoubleTapPaymentMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool
)

// MARK: -

func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl)
Expand Down
Expand Up @@ -647,6 +647,30 @@ public class CVComponentBodyText: CVComponentBase, CVComponent {
gestureLocation: .bodyText(item: item))
}

public override func findDoubleTapHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVDoubleTapHandler? {

guard let componentView = componentView as? CVComponentViewBodyText else {
owsFailDebug("Unexpected componentView.")
return nil
}

guard !shouldIgnoreEvents else {
return nil
}

let bodyTextLabel = componentView.bodyTextLabel
guard let item = bodyTextLabel.itemForGesture(sender: sender) else {
return nil
}
bodyTextLabel.animate(selectedItem: item)
return CVDoubleTapHandler(delegate: componentDelegate,
renderItem: renderItem,
gestureLocation: .bodyText(item: item))
}

// MARK: -

fileprivate class BodyTextRootView: ManualStackView {}
Expand Down
Expand Up @@ -1734,6 +1734,51 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
gestureLocation: .`default`)
}

public override func findDoubleTapHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVDoubleTapHandler? {

guard let componentView = componentView as? CVComponentViewMessage else {
owsFailDebug("Unexpected componentView.")
return nil
}

if let componentAndView = findActiveComponentAndView(key: .bodyText,
messageView: componentView,
ignoreMissing: true),
let handler = componentAndView.component.findDoubleTapHandler(sender: sender,
componentDelegate: componentDelegate,
componentView: componentAndView.componentView,
renderItem: renderItem) {
return handler
}

let doubleTapKeys: [CVComponentKey: CVDoubleTapHandler.GestureLocation] = [
.sticker: .sticker,
.bodyMedia: .media,
.audioAttachment: .media,
.genericAttachment: .media,
.quotedReply: .quotedReply,
.paymentAttachment: .paymentMessage
// TODO: linkPreview?
]
// Recognize the correct message type when tapping next to the message itself
let hotArea = UIEdgeInsets(hMargin: -.greatestFiniteMagnitude, vMargin: 0)
for (key, gestureLocation) in doubleTapKeys {
if let subcomponentView = componentView.subcomponentView(key: key),
subcomponentView.rootView.containsGestureLocation(sender, hotAreaInsets: hotArea) {
return CVDoubleTapHandler(delegate: componentDelegate,
renderItem: renderItem,
gestureLocation: gestureLocation)
}
}

return CVDoubleTapHandler(delegate: componentDelegate,
renderItem: renderItem,
gestureLocation: .`default`)
}

// For a configured & active cell, this will return the list of
// currently active subcomponents & their corresponding subcomponent
// views. This can be used for gesture dispatch, etc.
Expand Down
Expand Up @@ -495,6 +495,15 @@ public class CVComponentSystemMessage: CVComponentBase, CVRootComponent {
gestureLocation: .systemMessage)
}

public override func findDoubleTapHandler(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> CVDoubleTapHandler? {
return CVDoubleTapHandler(delegate: componentDelegate,
renderItem: renderItem,
gestureLocation: .systemMessage)
}

// MARK: -

// Used for rendering some portion of an Conversation View item.
Expand Down
8 changes: 8 additions & 0 deletions Signal/src/ViewControllers/ConversationView/CVViewState.swift
Expand Up @@ -119,6 +119,11 @@ public class CVViewState: NSObject {

public let collectionViewTapGestureRecognizer = UITapGestureRecognizer()
public let collectionViewLongPressGestureRecognizer = UILongPressGestureRecognizer()
public let collectionViewDoubleTapGestureRecognizer: UITapGestureRecognizer = {
let collectionViewDoubleTapGestureRecognizer = UITapGestureRecognizer()
collectionViewDoubleTapGestureRecognizer.numberOfTapsRequired = 2
return collectionViewDoubleTapGestureRecognizer
}()
public let collectionViewContextMenuGestureRecognizer = UILongPressGestureRecognizer()
public var collectionViewContextMenuSecondaryClickRecognizer: UITapGestureRecognizer?

Expand Down Expand Up @@ -299,6 +304,9 @@ extension ConversationViewController {
var collectionViewLongPressGestureRecognizer: UILongPressGestureRecognizer {
viewState.collectionViewLongPressGestureRecognizer
}
var collectionViewDoubleTapGestureRecognizer: UITapGestureRecognizer {
viewState.collectionViewDoubleTapGestureRecognizer
}
var collectionViewContextMenuGestureRecognizer: UILongPressGestureRecognizer {
viewState.collectionViewContextMenuGestureRecognizer
}
Expand Down
Expand Up @@ -113,6 +113,63 @@ extension ConversationViewController: CVComponentDelegate {
collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidEnd()
}

// MARK: - Double Tap

public func didDoubleTapTextViewItem(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {
AssertIsOnMainThread()

let messageActions = MessageActions.textActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

public func didDoubleTapMediaViewItem(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {
AssertIsOnMainThread()

let messageActions = MessageActions.mediaActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

public func didDoubleTapQuote(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {
AssertIsOnMainThread()

let messageActions = MessageActions.quotedMessageActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

public func didDoubleTapSystemMessage(_ cell: CVCell, itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()

let messageActions = MessageActions.infoMessageActions(itemViewModel: itemViewModel,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

public func didDoubleTapSticker(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {
AssertIsOnMainThread()

let messageActions = MessageActions.mediaActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

public func didDoubleTapPaymentMessage(_ cell: CVCell, itemViewModel: CVItemViewModelImpl, shouldAllowReply: Bool) {
AssertIsOnMainThread()

let messageActions = MessageActions.paymentActions(
itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self
)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}

// MARK: -

public func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {
Expand Down
Expand Up @@ -20,6 +20,10 @@ extension ConversationViewController: UIGestureRecognizerDelegate {
collectionViewLongPressGestureRecognizer.delegate = self
collectionView.addGestureRecognizer(collectionViewLongPressGestureRecognizer)

collectionViewDoubleTapGestureRecognizer.addTarget(self, action: #selector(handleDoubleTapGesture))
collectionViewDoubleTapGestureRecognizer.delegate = self
collectionView.addGestureRecognizer(collectionViewDoubleTapGestureRecognizer)

collectionViewContextMenuGestureRecognizer.addTarget(self, action: #selector(handleLongPressGesture))
collectionViewContextMenuGestureRecognizer.minimumPressDuration = 0.2
collectionViewContextMenuGestureRecognizer.delegate = self
Expand All @@ -40,6 +44,7 @@ extension ConversationViewController: UIGestureRecognizerDelegate {

collectionViewTapGestureRecognizer.require(toFail: collectionViewPanGestureRecognizer)
collectionViewTapGestureRecognizer.require(toFail: collectionViewLongPressGestureRecognizer)
collectionViewTapGestureRecognizer.require(toFail: collectionViewDoubleTapGestureRecognizer)

// Allow panning with trackpad
if #available(iOS 13.4, *) { collectionViewPanGestureRecognizer.allowedScrollTypesMask = .continuous }
Expand Down Expand Up @@ -85,8 +90,8 @@ extension ConversationViewController: UIGestureRecognizerDelegate {

public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// Support standard long press recognizing for body text cases, and context menu long press recognizing for everything else
let currentIsLongPressOrTap = (gestureRecognizer == collectionViewLongPressGestureRecognizer || gestureRecognizer == collectionViewContextMenuGestureRecognizer || gestureRecognizer == collectionViewTapGestureRecognizer)
let otherIsLongPressOrTap = (otherGestureRecognizer == collectionViewLongPressGestureRecognizer || otherGestureRecognizer == collectionViewContextMenuGestureRecognizer || otherGestureRecognizer == collectionViewTapGestureRecognizer)
let currentIsLongPressOrTap = (gestureRecognizer == collectionViewLongPressGestureRecognizer || gestureRecognizer == collectionViewContextMenuGestureRecognizer || gestureRecognizer == collectionViewTapGestureRecognizer || gestureRecognizer == collectionViewDoubleTapGestureRecognizer)
let otherIsLongPressOrTap = (otherGestureRecognizer == collectionViewLongPressGestureRecognizer || otherGestureRecognizer == collectionViewContextMenuGestureRecognizer || otherGestureRecognizer == collectionViewTapGestureRecognizer || otherGestureRecognizer == collectionViewDoubleTapGestureRecognizer)
return currentIsLongPressOrTap && otherIsLongPressOrTap
}

Expand Down Expand Up @@ -174,6 +179,17 @@ extension ConversationViewController: UIGestureRecognizerDelegate {
}
}

@objc
func handleDoubleTapGesture(_ sender: UITapGestureRecognizer) {
guard sender.state == .recognized,
let cell = findCell(forGesture: sender),
let doubleTapHandler = findDoubleTapHandler(sender: sender) else {
return
}

doubleTapHandler.handleDoubleTap(cell: cell)
}

@objc
func handleSecondaryClickGesture(_ sender: UITapGestureRecognizer) {
guard let cell = findCell(forGesture: sender) else {
Expand Down Expand Up @@ -203,6 +219,13 @@ extension ConversationViewController: UIGestureRecognizerDelegate {
return longPressHandler
}

private func findDoubleTapHandler(sender: UITapGestureRecognizer) -> CVDoubleTapHandler? {
guard let cell = findCell(forGesture: sender) else {
return nil
}
return cell.findDoubleTapHandler(sender: sender, componentDelegate: componentDelegate)
}

// MARK: - VoiceOver Custom Actions

func updateAccessibilityCustomActionsForCell(cell: CVCell) {
Expand Down Expand Up @@ -417,6 +440,68 @@ public struct CVLongPressHandler {

// MARK: -

public struct CVDoubleTapHandler {
private weak var delegate: CVComponentDelegate?
let renderItem: CVRenderItem
let itemViewModel: CVItemViewModelImpl

enum GestureLocation {
case `default`
case media
case sticker
case quotedReply
case systemMessage
case paymentMessage
case bodyText(item: CVTextLabel.Item)
}
let gestureLocation: GestureLocation

init(delegate: CVComponentDelegate,
renderItem: CVRenderItem,
gestureLocation: GestureLocation) {
self.delegate = delegate
self.renderItem = renderItem
self.gestureLocation = gestureLocation

// TODO: shouldAutoUpdate?
self.itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
}

func handleDoubleTap(cell: CVCell) {
guard let delegate = self.delegate else {
owsFailDebug("Missing delegate.")
return
}

let shouldAllowReply = delegate.shouldAllowReplyForItem(itemViewModel)

switch gestureLocation {
case .`default`:
delegate.didDoubleTapTextViewItem(cell, itemViewModel: itemViewModel, shouldAllowReply: shouldAllowReply)
case .media:
delegate.didDoubleTapMediaViewItem(cell,
itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply)
case .sticker:
delegate.didDoubleTapSticker(cell,
itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply)
case .quotedReply:
delegate.didDoubleTapQuote(cell,
itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply)
case .systemMessage:
delegate.didDoubleTapSystemMessage(cell, itemViewModel: itemViewModel)
case .paymentMessage:
delegate.didDoubleTapPaymentMessage(cell, itemViewModel: itemViewModel, shouldAllowReply: shouldAllowReply)
case .bodyText:
break
}
}
}

// MARK: -

public class CVPanHandler {
public enum PanType {
case messageSwipeAction
Expand Down