Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Signal/ConversationView/CVViewState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public class CVViewState: NSObject {

public var collectionViewGestureRecongnizersConfigured = false
public let collectionViewTapGestureRecognizer = SingleOrDoubleTapGestureRecognizer()
var doubleTapReactionMessageId: String?
public let collectionViewLongPressGestureRecognizer = UILongPressGestureRecognizer()
public let collectionViewContextMenuGestureRecognizer = UILongPressGestureRecognizer()
public var collectionViewContextMenuSecondaryClickRecognizer = UITapGestureRecognizer()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate {
// MARK: - Tap

public func handleSingleTap(_ sender: SingleOrDoubleTapGestureRecognizer) -> Bool {
viewState.doubleTapReactionMessageId = nil

// Stop any recording voice memos.
finishRecordingVoiceMessage(sendImmediately: false)

Expand All @@ -151,13 +153,30 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate {
return false
}

return cell.handleTap(sender: sender, componentDelegate: self)
let wasHandled = cell.handleTap(sender: sender, componentDelegate: self)
if !wasHandled, let message = doubleTapReactionMessage(in: cell, sender: sender) {
viewState.doubleTapReactionMessageId = message.uniqueId
}
return wasHandled
}

public func handleDoubleTap(_ sender: SingleOrDoubleTapGestureRecognizer) -> Bool {
guard
!isShowingSelectionUI,
collectionViewActiveContextMenuInteraction?.contextMenuVisible != true
else {
return false
}
guard let cell = findCell(forGesture: sender) else {
return false
}
if
let message = doubleTapReactionMessage(in: cell, sender: sender),
message.uniqueId == viewState.doubleTapReactionMessageId
{
addDoubleTapHeartReaction(to: message.uniqueId)
return true
}
guard cell.canHandleDoubleTap(sender: sender, componentDelegate: self) else {
return false
}
Expand All @@ -166,10 +185,59 @@ extension ConversationViewController: SingleOrDoubleTapGestureDelegate {
}

public func didEndGesture(_ sender: SingleOrDoubleTapGestureRecognizer, wasHandled: Bool) {
viewState.doubleTapReactionMessageId = nil
if !wasHandled {
dismissKeyboard()
}
}

private func doubleTapReactionMessage(in cell: CVCell, sender: UIGestureRecognizer) -> TSIncomingMessage? {
guard
!isShowingSelectionUI,
thread.canSendReactionToThread,
cell.renderItem?.componentState.bodyText != nil,
let message = cell.renderItem?.interaction as? TSIncomingMessage,
shouldShowReactionPickerForInteraction(message),
let messageView = cell.componentView as? CVComponentMessage.CVComponentViewMessage,
let bodyTextView = messageView.bodyTextView,
bodyTextView.rootView.containsGestureLocation(sender)
else {
return nil
}
return message
}

private func addDoubleTapHeartReaction(to messageId: String) {
SSKEnvironment.shared.databaseStorageRef.asyncWrite { tx -> Bool in
Self.addDoubleTapHeartReaction(to: messageId, tx: tx)
} completion: { didReact in
if didReact {
ImpactHapticFeedback.impactOccurred(style: .light)
}
}
}

// Keep the read and write in one transaction so repeated gestures are idempotent.
nonisolated static func addDoubleTapHeartReaction(to messageId: String, tx: DBWriteTransaction) -> Bool {
guard
let message = TSMessage.fetchMessageViaCache(uniqueId: messageId, transaction: tx) as? TSIncomingMessage,
!message.wasRemotelyDeleted,
let thread = message.thread(tx: tx),
thread.canSendReactionToThread,
let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx)?.aci,
message.reaction(for: localAci, tx: tx)?.emoji != "❤️"
else {
return false
}
ReactionManager.localUserReacted(
to: messageId,
emoji: "❤️",
isRemoving: false,
tx: tx,
)
return true
}

}

extension ConversationViewController {
Expand Down
82 changes: 82 additions & 0 deletions Signal/test/ViewControllers/ConversationViewControllerTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import XCTest

@testable import Signal
@testable import SignalServiceKit

class ConversationViewControllerTest: SignalBaseTest {

Expand Down Expand Up @@ -135,3 +136,84 @@ class ConversationViewControllerTest: SignalBaseTest {
)
}
}

class DoubleTapHeartReactionTest: SignalBaseTest {
override func setUp() {
super.setUp()
write { tx in
(DependenciesBridge.shared.registrationStateChangeManager as! RegistrationStateChangeManagerImpl).registerForTests(
localIdentifiers: .forUnitTests,
tx: tx,
)
}
}

func testAddsHeartAndMarksItRead() throws {
try write { tx in
let message = IncomingMessageFactory().create(transaction: tx)

XCTAssertTrue(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))

let reaction = try XCTUnwrap(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx))
XCTAssertEqual(reaction.emoji, "❤️")
XCTAssertTrue(reaction.read)
}
}

func testRepeatedDoubleTapKeepsTheSameReaction() throws {
let message = IncomingMessageFactory().create()
let firstReactionId = try write { tx in
XCTAssertTrue(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))
return try XCTUnwrap(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx)).uniqueId
}

try write { tx in
XCTAssertFalse(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))
let reaction = try XCTUnwrap(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx))
XCTAssertEqual(reaction.uniqueId, firstReactionId)
XCTAssertEqual(reaction.emoji, "❤️")
}
}

func testReplacesAnotherReaction() throws {
try write { tx in
let message = IncomingMessageFactory().create(transaction: tx)
message.recordReaction(
for: LocalIdentifiers.forUnitTests.aci,
emoji: "👍",
sentAtTimestamp: 1,
tx: tx,
)

XCTAssertTrue(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))
let reaction = try XCTUnwrap(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx))
XCTAssertEqual(reaction.emoji, "❤️")
}
}

func testRemotelyDeletedMessageDoesNotReceiveAReaction() {
let message = IncomingMessageFactory().create()
write { tx in
message.updateWithRemotelyDeletedAndRemoveRenderableContent(with: tx)
}

write { tx in
XCTAssertFalse(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))
XCTAssertNil(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx))
}
}

func testMissingMessageDoesNotReceiveAReaction() {
write { tx in
XCTAssertFalse(ConversationViewController.addDoubleTapHeartReaction(to: UUID().uuidString, tx: tx))
}
}

func testOutgoingMessageDoesNotReceiveAReaction() {
write { tx in
let message = OutgoingMessageFactory().create(transaction: tx)
XCTAssertFalse(ConversationViewController.addDoubleTapHeartReaction(to: message.uniqueId, tx: tx))
XCTAssertNil(message.reaction(for: LocalIdentifiers.forUnitTests.aci, tx: tx))
}
}
}