Skip to content
Merged
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
60 changes: 34 additions & 26 deletions FlipcashTests/Chat/ChatSwipeToReplyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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"),
Expand All @@ -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))
}
}

Expand Down
24 changes: 13 additions & 11 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatColumnCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -229,7 +232,6 @@ public class ChatColumnCell: UICollectionViewCell {
authorAvatar.isHidden = true
authorAvatar.reset()
authorID = nil
reservesAuthorGutter = false
columnLeading?.constant = Self.rowInset
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatSwipeToReply.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down
6 changes: 3 additions & 3 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading