restrict the reply swipe to right-to-left
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s

- 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.
This commit is contained in:
wt committed 2026-08-29 22:49:11 +07:00
1 parent 2130a76735
commit 2868a13abd
3 files changed
+53 -31

No files matched your search

+1 -1
View File
@@ -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
}
@@ -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
+45 -28
View File
@@ -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
)
}