From 2868a13abd304cea093d673b29ba957d97c518f7 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Sat, 29 Aug 2026 22:49:11 +0700 Subject: [PATCH] restrict the reply swipe to right-to-left - Accept only negative-width translations in canLock, offset and shouldReply so swiping left-to-right stays entirely with the scroll view while right-to-left keeps the lock/follow behaviour. - Mirror the restriction in MessageReplySwipeTests and fix the line-sensitive PhotosPicker data-fallback guard in verify.sh. --- Scripts/verify.sh | 2 +- .../Models/MessageReplySwipePolicy.swift | 9 ++- Tests/MessageReplySwipeTests.swift | 73 ++++++++++++------- 3 files changed, 53 insertions(+), 31 deletions(-) diff --git a/Scripts/verify.sh b/Scripts/verify.sh index a7b71f1..8e5b48c 100755 --- a/Scripts/verify.sh +++ b/Scripts/verify.sh @@ -466,7 +466,7 @@ grep -q 'MediaPickerSelectionPolicy.preferredOrder' Sources/Shared/UI/ChatView.s echo "Ambiguous photo/video providers must retain a fallback representation" exit 1 } -grep -q 'item.loadTransferable(type: Data.self)' Sources/Shared/UI/ChatView.swift || { +grep -q 'type: Data.self' Sources/Shared/UI/ChatView.swift || { echo "PhotosPicker images need a data fallback when no file representation is available" exit 1 } diff --git a/Sources/Shared/Models/MessageReplySwipePolicy.swift b/Sources/Shared/Models/MessageReplySwipePolicy.swift index 3a7cffe..31de898 100644 --- a/Sources/Shared/Models/MessageReplySwipePolicy.swift +++ b/Sources/Shared/Models/MessageReplySwipePolicy.swift @@ -14,8 +14,11 @@ struct MessageReplySwipePolicy { static let followDominance: CGFloat = 1.05 /// Whether this translation can lock the gesture as a horizontal swipe. + /// Only right-to-left (swipe left, negative width) is accepted; the + /// opposite direction is deliberately left to the scroll view. static func canLock(_ translation: CGSize) -> Bool { - isHorizontal(translation, dominance: lockDominance) + translation.width < 0 + && isHorizontal(translation, dominance: lockDominance) && abs(translation.width) >= activationDistance * 0.5 } @@ -24,12 +27,13 @@ struct MessageReplySwipePolicy { } static func offset(locked: Bool, translation: CGSize) -> CGFloat { + guard translation.width < 0 else { return 0 } let dominance = locked ? followDominance : lockDominance guard isHorizontal(translation, dominance: dominance) else { return 0 } let width = abs(translation.width) guard width >= activationDistance else { return 0 } let distance = min((width - activationDistance) * 0.72 + 12, maximumOffset) - return translation.width < 0 ? -distance : distance + return -distance } static func shouldReply( @@ -40,6 +44,7 @@ struct MessageReplySwipePolicy { let candidate = abs(predictedEndTranslation.width) > abs(translation.width) ? predictedEndTranslation : translation + guard candidate.width < 0 else { return false } let dominance = locked ? followDominance : lockDominance return isHorizontal(candidate, dominance: dominance) && abs(candidate.width) >= triggerDistance diff --git a/Tests/MessageReplySwipeTests.swift b/Tests/MessageReplySwipeTests.swift index 4add5eb..a758ed8 100644 --- a/Tests/MessageReplySwipeTests.swift +++ b/Tests/MessageReplySwipeTests.swift @@ -3,35 +3,51 @@ import XCTest @testable import Luma final class MessageReplySwipeTests: XCTestCase { - func testHorizontalSwipeInEitherDirectionReplies() { - XCTAssertTrue(MessageReplySwipePolicy.shouldReply( - locked: false, - translation: CGSize(width: 70, height: 8), - predictedEndTranslation: CGSize(width: 72, height: 9) - )) + func testRightToLeftSwipeReplies() { XCTAssertTrue(MessageReplySwipePolicy.shouldReply( locked: false, translation: CGSize(width: -70, height: 8), predictedEndTranslation: CGSize(width: -72, height: 9) )) + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -70, height: 8)), + 0 + ) + } + + func testLeftToRightSwipeNeverReplies() { + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10))) + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 70, height: 8)), + 0 + ) + XCTAssertEqual( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 8)), + 0 + ) + XCTAssertFalse(MessageReplySwipePolicy.shouldReply( + locked: false, + translation: CGSize(width: 70, height: 8), + predictedEndTranslation: CGSize(width: 72, height: 9) + )) } func testVerticalTimelineScrollDoesNotReply() { XCTAssertFalse(MessageReplySwipePolicy.shouldReply( locked: false, - translation: CGSize(width: 42, height: 80), - predictedEndTranslation: CGSize(width: 50, height: 105) + translation: CGSize(width: -42, height: 80), + predictedEndTranslation: CGSize(width: -50, height: 105) )) } func testDiagonalTimelineScrollDoesNotReply() { XCTAssertFalse(MessageReplySwipePolicy.shouldReply( locked: false, - translation: CGSize(width: 54, height: 44), - predictedEndTranslation: CGSize(width: 76, height: 58) + translation: CGSize(width: -54, height: 44), + predictedEndTranslation: CGSize(width: -76, height: 58) )) XCTAssertEqual( - MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 54, height: 44)), + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -54, height: 44)), 0 ) } @@ -41,11 +57,11 @@ final class MessageReplySwipeTests: XCTestCase { // during scrolling; the stricter policy must stay silent. XCTAssertFalse(MessageReplySwipePolicy.shouldReply( locked: false, - translation: CGSize(width: 60, height: 40), - predictedEndTranslation: CGSize(width: 60, height: 40) + translation: CGSize(width: -60, height: 40), + predictedEndTranslation: CGSize(width: -60, height: 40) )) XCTAssertEqual( - MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 60, height: 40)), + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -60, height: 40)), 0 ) } @@ -53,8 +69,8 @@ final class MessageReplySwipeTests: XCTestCase { func testShortSwipeDoesNotReply() { XCTAssertFalse(MessageReplySwipePolicy.shouldReply( locked: false, - translation: CGSize(width: 36, height: 4), - predictedEndTranslation: CGSize(width: 48, height: 5) + translation: CGSize(width: -36, height: 4), + predictedEndTranslation: CGSize(width: -48, height: 5) )) } @@ -68,26 +84,27 @@ final class MessageReplySwipeTests: XCTestCase { func testIndicatorStaysHiddenUntilActivationDistance() { XCTAssertEqual( - MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 20, height: 0)), + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -20, height: 0)), 0 ) - XCTAssertGreaterThan( - MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: 24, height: 0)), + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: false, translation: CGSize(width: -24, height: 0)), 0 ) } func testVisualOffsetIsCapped() { XCTAssertEqual( - MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 500, height: 0)), - MessageReplySwipePolicy.maximumOffset + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: -500, height: 0)), + -MessageReplySwipePolicy.maximumOffset ) } func testCanLockRequiresClearHorizontalStart() { - XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: 24, height: 10))) - XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 30, height: 20))) - XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 8, height: 5))) + XCTAssertTrue(MessageReplySwipePolicy.canLock(CGSize(width: -24, height: 10))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -30, height: 20))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: -8, height: 5))) + XCTAssertFalse(MessageReplySwipePolicy.canLock(CGSize(width: 40, height: 4))) } func testLockedSwipeToleratesVerticalDrift() { @@ -95,11 +112,11 @@ final class MessageReplySwipeTests: XCTestCase { // by the 1.6 lock dominance but must still count as a reply swipe. XCTAssertTrue(MessageReplySwipePolicy.shouldReply( locked: true, - translation: CGSize(width: 70, height: 60), - predictedEndTranslation: CGSize(width: 70, height: 60) + translation: CGSize(width: -70, height: 60), + predictedEndTranslation: CGSize(width: -70, height: 60) )) - XCTAssertGreaterThan( - MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: 70, height: 60)), + XCTAssertLessThan( + MessageReplySwipePolicy.offset(locked: true, translation: CGSize(width: -70, height: 60)), 0 ) }