diff --git a/Session/Conversations V2/ConversationVC+Interaction.swift b/Session/Conversations V2/ConversationVC+Interaction.swift index 5d83f016c..93796b4e5 100644 --- a/Session/Conversations V2/ConversationVC+Interaction.swift +++ b/Session/Conversations V2/ConversationVC+Interaction.swift @@ -208,4 +208,8 @@ extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuAc urlModal.modalTransitionStyle = .crossDissolve present(urlModal, animated: true, completion: nil) } + + func handleReplyButtonTapped(for viewItem: ConversationViewItem) { + reply(viewItem) + } } diff --git a/Session/Conversations V2/Message Cells/Content Views/QuoteView.swift b/Session/Conversations V2/Message Cells/Content Views/QuoteView.swift index d28cae013..12e9b46bd 100644 --- a/Session/Conversations V2/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations V2/Message Cells/Content Views/QuoteView.swift @@ -90,7 +90,7 @@ final class QuoteView : UIView { static let iconSize: CGFloat = 24 static let labelStackViewSpacing: CGFloat = 2 static let labelStackViewVMargin: CGFloat = 4 - static let cancelButtonSize: CGFloat = 32 + static let cancelButtonSize: CGFloat = 33 // MARK: Lifecycle init(for viewItem: ConversationViewItem, direction: Direction, hInset: CGFloat, maxWidth: CGFloat) { diff --git a/Session/Conversations V2/Message Cells/MessageCell.swift b/Session/Conversations V2/Message Cells/MessageCell.swift index c580f1268..86851bf9c 100644 --- a/Session/Conversations V2/Message Cells/MessageCell.swift +++ b/Session/Conversations V2/Message Cells/MessageCell.swift @@ -56,4 +56,5 @@ protocol MessageCellDelegate { func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) func showFullText(_ viewItem: ConversationViewItem) func openURL(_ url: URL) + func handleReplyButtonTapped(for viewItem: ConversationViewItem) } diff --git a/Session/Conversations V2/Message Cells/VisibleMessageCell.swift b/Session/Conversations V2/Message Cells/VisibleMessageCell.swift index 39f9bbfc1..ea35c1836 100644 --- a/Session/Conversations V2/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations V2/Message Cells/VisibleMessageCell.swift @@ -1,6 +1,7 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDelegate { private var unloadContent: (() -> Void)? + private var previousX: CGFloat = 0 var albumView: MediaAlbumView? var bodyTextView: UITextView? var mediaTextOverlayView: MediaTextOverlayView? @@ -76,12 +77,37 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe return result }() + private lazy var replyButton: UIView = { + let result = UIView() + let size = VisibleMessageCell.replyButtonSize + 8 + result.set(.width, to: size) + result.set(.height, to: size) + result.layer.borderWidth = 1 + result.layer.borderColor = Colors.text.cgColor + result.layer.cornerRadius = size / 2 + result.layer.masksToBounds = true + result.alpha = 0 + return result + }() + + private lazy var replyIconImageView: UIImageView = { + let result = UIImageView() + let size = VisibleMessageCell.replyButtonSize + result.set(.width, to: size) + result.set(.height, to: size) + result.image = UIImage(named: "ic_reply") + return result + }() + // MARK: Settings private static let messageStatusImageViewSize: CGFloat = 16 private static let authorLabelBottomSpacing: CGFloat = 4 private static let groupThreadHSpacing: CGFloat = 12 private static let profilePictureSize = Values.verySmallProfilePictureSize private static let authorLabelInset: CGFloat = 12 + private static let replyButtonSize: CGFloat = 24 + private static let maxBubbleTranslationX: CGFloat = 40 + private static let swipeToReplyThreshold: CGFloat = 130 static let smallCornerRadius: CGFloat = 4 static let largeCornerRadius: CGFloat = 18 static let contactThreadHSpacing = Values.mediumSpacing @@ -132,6 +158,12 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1) messageStatusImageViewWidthConstraint.isActive = true messageStatusImageViewHeightConstraint.isActive = true + // Reply button + addSubview(replyButton) + replyButton.addSubview(replyIconImageView) + replyIconImageView.center(in: replyButton) + replyButton.pin(.left, to: .right, of: bubbleView, withInset: Values.smallSpacing) + replyButton.center(.vertical, in: bubbleView) // Remaining constraints authorLabel.pin(.left, to: .left, of: bubbleView, withInset: VisibleMessageCell.authorLabelInset) } @@ -146,6 +178,9 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe doubleTapGestureRecognizer.numberOfTapsRequired = 2 addGestureRecognizer(doubleTapGestureRecognizer) tapGestureRecognizer.require(toFail: doubleTapGestureRecognizer) + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + panGestureRecognizer.delegate = self + addGestureRecognizer(panGestureRecognizer) } // MARK: Updating @@ -304,6 +339,9 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe override func prepareForReuse() { super.prepareForReuse() unloadContent?() + let viewsToMove = [ bubbleView, profilePictureView, replyButton ] + viewsToMove.forEach { $0.transform = .identity } + replyButton.alpha = 0 } // MARK: Interaction @@ -317,6 +355,22 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe return super.hitTest(point, with: event) } + override func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer { + let locationInBubbleView = panGestureRecognizer.location(in: bubbleView) + guard bubbleView.bounds.contains(locationInBubbleView) else { return false } + let v = panGestureRecognizer.velocity(in: self) + guard v.x < 0 else { return false } + return abs(v.x) > abs(v.y) + } else { + return true + } + } + @objc func handleLongPress() { guard let viewItem = viewItem else { return } delegate?.handleViewItemLongPressed(viewItem) @@ -324,7 +378,18 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let viewItem = viewItem else { return } - delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) + let location = gestureRecognizer.location(in: self) + if replyButton.frame.contains(location) { + let viewsToMove = [ bubbleView, profilePictureView, replyButton ] + UIView.animate(withDuration: 0.25) { + viewsToMove.forEach { $0.transform = .identity } + self.replyButton.alpha = 0 + } + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + delegate?.handleReplyButtonTapped(for: viewItem) + } else { + delegate?.handleViewItemTapped(viewItem, gestureRecognizer: gestureRecognizer) + } } @objc private func handleDoubleTap() { @@ -332,6 +397,39 @@ final class VisibleMessageCell : MessageCell, UITextViewDelegate, BodyTextViewDe delegate?.handleViewItemDoubleTapped(viewItem) } + @objc private func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { + let viewsToMove = [ bubbleView, profilePictureView, replyButton ] + let translationX = gestureRecognizer.translation(in: self).x.clamp(-CGFloat.greatestFiniteMagnitude, 0) + switch gestureRecognizer.state { + case .changed: + let damping: CGFloat = 20 + let sign: CGFloat = -1 + let x = (damping * (sqrt(abs(translationX)) / sqrt(damping))) * sign + viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: x, y: 0) } + replyButton.alpha = abs(translationX) / VisibleMessageCell.maxBubbleTranslationX + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold && abs(previousX) < VisibleMessageCell.swipeToReplyThreshold { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + } + previousX = translationX + case .ended, .cancelled: + if abs(translationX) > VisibleMessageCell.swipeToReplyThreshold { + guard let viewItem = viewItem else { return } + let viewsToMove = [ bubbleView, profilePictureView, replyButton ] + UIView.animate(withDuration: 0.25) { + viewsToMove.forEach { $0.transform = .identity } + self.replyButton.alpha = 0 + } + delegate?.handleReplyButtonTapped(for: viewItem) + } else { + UIView.animate(withDuration: 0.25) { + viewsToMove.forEach { $0.transform = CGAffineTransform(translationX: -VisibleMessageCell.maxBubbleTranslationX, y: 0) } + self.replyButton.alpha = 1 + } + } + default: break + } + } + func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { delegate?.openURL(URL) return false