From a15c44e1f64dd908dd81b61487c7147628c7d704 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 16:20:07 -0400 Subject: [PATCH] fix(chat): arm the reply swipe only where the bubble is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The leading strip went inert only on a row that reserves the author gutter, which is an incoming row of a group. Every other row armed from its leading edge, where an outgoing bubble hugs the far side and there is nothing under the finger — in a DM and in a group alike. `allowsReplySwipe(at:)` asks where the bubble sits rather than what the row reserves: a point in the strip starts a swipe only when the bubble covers it, which is true for exactly one kind of row. | Row | Before | After | |---|---|---| | Group, incoming | inert | inert | | Group, outgoing | armed | inert | | DM, incoming | armed | armed | | DM, outgoing | armed | inert | A drag that starts on a bubble always replies, and everything outside the strip is unchanged — the empty transcript on the trailing side of an incoming row still swipes. `reservesAuthorGutter` goes with the old rule: the column's leading inset already puts a group bubble past the gutter, so the bubble's own frame carries the distinction. Mirrors code-payments/code-android-app#1501, row for row. --- .../Chat/ChatSwipeToReplyTests.swift | 60 +++++++++++-------- .../FlipcashUI/Chat/ChatColumnCell.swift | 24 ++++---- .../FlipcashUI/Chat/ChatSwipeToReply.swift | 4 +- .../FlipcashUI/Chat/ChatViewController.swift | 6 +- 4 files changed, 52 insertions(+), 42 deletions(-) diff --git a/FlipcashTests/Chat/ChatSwipeToReplyTests.swift b/FlipcashTests/Chat/ChatSwipeToReplyTests.swift index 811c179bc..e87d50320 100644 --- a/FlipcashTests/Chat/ChatSwipeToReplyTests.swift +++ b/FlipcashTests/Chat/ChatSwipeToReplyTests.swift @@ -186,9 +186,9 @@ struct ChatColumnCellSwipeOffsetTests { } } -@Suite("The author gutter and the reply swipe") +@Suite("The leading strip and the reply swipe") @MainActor -struct ChatColumnCellAuthorGutterTests { +struct ChatColumnCellLeadingStripTests { private func cell(for message: ChatMessage) -> ChatMessageCell { let cell = ChatMessageCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60)) @@ -200,7 +200,7 @@ struct ChatColumnCellAuthorGutterTests { private func incomingGroupMessage() -> ChatMessage { ChatMessage( id: "1", - text: "hi", + text: "hi there", sender: .other, actions: [.reply], author: ChatAuthor(id: UserID(), name: "KT"), @@ -209,63 +209,71 @@ struct ChatColumnCellAuthorGutterTests { } /// The strip the face sits in: the row's own margin plus the gutter it gives up. - private var insideGutter: CGPoint { + private var insideStrip: CGPoint { CGPoint(x: ChatColumnCell.rowInset + ChatColumnCell.authorGutterWidth - 1, y: 30) } - private var beyondGutter: CGPoint { + private var beyondStrip: CGPoint { CGPoint(x: ChatColumnCell.rowInset + ChatColumnCell.authorGutterWidth + 1, y: 30) } @Test("A point in an attributed incoming row's gutter starts no reply swipe") func attributedIncoming_gutterIsExcluded() { let cell = cell(for: incomingGroupMessage()) - #expect(cell.isInAuthorGutter(insideGutter)) - #expect(cell.isInAuthorGutter(CGPoint(x: 0, y: 30))) + #expect(cell.allowsReplySwipe(at: insideStrip) == false) + #expect(cell.allowsReplySwipe(at: CGPoint(x: 0, y: 30)) == false) } @Test("The rest of an attributed incoming row still swipes") func attributedIncoming_bubbleSideIsIncluded() { let cell = cell(for: incomingGroupMessage()) - #expect(cell.isInAuthorGutter(beyondGutter) == false) - #expect(cell.isInAuthorGutter(CGPoint(x: 200, y: 30)) == false) + #expect(cell.allowsReplySwipe(at: beyondStrip)) + #expect(cell.allowsReplySwipe(at: CGPoint(x: 200, y: 30))) } - @Test("A row that draws no face but holds the gutter open excludes it too") - func attributedIncoming_midRunExcludesGutter() { + @Test("A row that draws no face but holds the gutter open excludes the strip too") + func attributedIncoming_midRunExcludesStrip() { // The middle of a run hides the avatar; the gutter is the transcript's, so the strip is // still the face's and still off limits. let message = ChatMessage( id: "1", - text: "hi", + text: "hi there", sender: .other, isContinuedByNext: true, actions: [.reply], author: ChatAuthor(id: UserID(), name: "KT"), isAttributedTranscript: true ) - #expect(cell(for: message).isInAuthorGutter(insideGutter)) + #expect(cell(for: message).allowsReplySwipe(at: insideStrip) == false) } - @Test("A DM row reserves no gutter, so its leading edge swipes") - func dm_hasNoGutter() { - let cell = cell(for: ChatMessage(id: "1", text: "hi", sender: .other, actions: [.reply])) - #expect(cell.isInAuthorGutter(insideGutter) == false) + @Test("A DM row swipes in the strip, because that is where its bubble starts") + func dmIncoming_bubbleFillsTheStrip() { + let cell = cell(for: ChatMessage(id: "1", text: "hi there", sender: .other, actions: [.reply])) + #expect(cell.allowsReplySwipe(at: insideStrip)) + #expect(cell.allowsReplySwipe(at: beyondStrip)) } - @Test("The viewer's own row in a group reserves no gutter") - func ownRowInGroup_hasNoGutter() { - let message = ChatMessage( - id: "1", text: "hi", sender: .me, actions: [.reply], isAttributedTranscript: true - ) - #expect(cell(for: message).isInAuthorGutter(insideGutter) == false) + @Test("The strip beside the viewer's own bubble starts no reply swipe, DM or group") + func ownRow_excludesTheStrip() { + let dm = cell(for: ChatMessage(id: "1", text: "hi there", sender: .me, actions: [.reply])) + #expect(dm.allowsReplySwipe(at: insideStrip) == false) + #expect(dm.allowsReplySwipe(at: beyondStrip)) + + let group = cell(for: ChatMessage( + id: "1", text: "hi there", sender: .me, actions: [.reply], isAttributedTranscript: true + )) + #expect(group.allowsReplySwipe(at: insideStrip) == false) } - @Test("Reuse clears the gutter") - func reuse_clearsTheGutter() { + @Test("The rule follows the bubble when a recycled cell is reconfigured") + func reuse_followsTheNewBubble() { let cell = cell(for: incomingGroupMessage()) + #expect(cell.allowsReplySwipe(at: insideStrip) == false) cell.prepareForReuse() - #expect(cell.isInAuthorGutter(insideGutter) == false) + cell.configure(with: ChatMessage(id: "2", text: "hi there", sender: .other, actions: [.reply]), maxWidth: 250) + cell.layoutIfNeeded() + #expect(cell.allowsReplySwipe(at: insideStrip)) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift index 96d29677d..91007feaa 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift @@ -49,9 +49,6 @@ public class ChatColumnCell: UICollectionViewCell { /// Who the gutter face currently belongs to, so the tap knows whose profile to open. Nil on /// every row that draws no face. private var authorID: UserID? - /// Whether this row holds the leading gutter open — every incoming row of an attributed - /// transcript, whether or not it draws a face there. - private var reservesAuthorGutter = false /// The message's stable id while this row is failed and tappable; nil otherwise. private var retryID: String? /// Tap-to-retry recognizer, enabled only while this row is failed so non-failed bubbles don't @@ -168,12 +165,18 @@ public class ChatColumnCell: UICollectionViewCell { /// the space the row actually has. public static let authorGutterWidth = ChatAuthorAvatarView.size + 8 - /// Whether a point in this cell's coordinates falls in the author gutter. The reply swipe asks - /// before it starts: the gutter is the face's own tap target, so a drag beginning there is - /// someone reaching for a profile rather than for reply. - func isInAuthorGutter(_ point: CGPoint) -> Bool { - guard reservesAuthorGutter else { return false } - return point.x < Self.rowInset + Self.authorGutterWidth + /// Whether a drag starting at this point, in the cell's own coordinates, may reply to the row. + /// + /// The bubble always answers yes, wherever it sits. The strip beside it at the row's leading + /// edge does not: in an attributed transcript that strip is the author's face, whose own tap + /// target it must stay, and on every other row it is empty transcript beside a bubble that hugs + /// the far edge — nothing the finger was reaching for, in the band the screen-edge back gesture + /// also starts in. One rule covers both, because where the bubble sits is what tells them apart. + func allowsReplySwipe(at point: CGPoint) -> Bool { + guard point.x < Self.rowInset + Self.authorGutterWidth else { return true } + guard let content else { return false } + let bubble = content.convert(content.bounds, to: self) + return point.x >= bubble.minX && point.x <= bubble.maxX } /// How far the row's content is dragged towards the trailing edge by the reply swipe. @@ -229,7 +232,6 @@ public class ChatColumnCell: UICollectionViewCell { authorAvatar.isHidden = true authorAvatar.reset() authorID = nil - reservesAuthorGutter = false columnLeading?.constant = Self.rowInset } @@ -270,7 +272,7 @@ public class ChatColumnCell: UICollectionViewCell { // held open for every incoming row of a group chat — the middle of a run, and a row whose // sender no roster could name — so the bubbles share one leading edge instead of stepping // in and out as faces come and go. - reservesAuthorGutter = message.isAttributedTranscript && message.sender != .me + let reservesAuthorGutter = message.isAttributedTranscript && message.sender != .me columnLeading?.constant = reservesAuthorGutter ? Self.rowInset + Self.authorGutterWidth : Self.rowInset guard let author = message.author, message.sender != .me else { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift index 3257f24dd..4d27767cd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift @@ -34,8 +34,8 @@ final class ChatSwipeToReply: NSObject { let recognizer = UIPanGestureRecognizer() /// The row a drag from this point may reply to, or nil where no reply starts — a row that - /// offers no Reply, and the author gutter, whose face is its own target. The transcript answers - /// this; the gesture does not know what a message is. + /// offers no Reply, and the strip at the row's leading edge wherever the bubble isn't. The + /// transcript answers this; the gesture does not know what a message is. var rowForSwipe: ((CGPoint) -> (cell: ChatColumnCell, stableID: String)?)? /// Whether the transcript is busy — mid-update, or showing a context menu. var isBlocked: (() -> Bool)? diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index fd3fae7e1..b91f0ff23 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -241,9 +241,9 @@ public final class ChatViewController: UICollectionViewController { guard let self, let indexPath = self.collectionView.indexPathForItem(at: point), let cell = self.collectionView.cellForItem(at: indexPath) as? ChatColumnCell, - // A drag that starts on an author's face belongs to the face — see - // `ChatColumnCell.isInAuthorGutter`. - !cell.isInAuthorGutter(cell.convert(point, from: self.collectionView)), + // The strip at the row's leading edge is the author's face, or empty transcript + // — see `ChatColumnCell.allowsReplySwipe(at:)`. + cell.allowsReplySwipe(at: cell.convert(point, from: self.collectionView)), case .message(let message) = self.items[indexPath.item], message.actions.contains(.reply) else { return nil }