From cc55d5257606ad8d5efafd7bd8761ad577e929a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:14:16 -0600 Subject: [PATCH 1/4] fix(chat): divide the Sent Back footer from the card, and read a received return "to You" The Sent Back footer gets a full-width one-pixel divider along its top edge, in Color.rowSeparator, the color Row draws its separator in. The Send Back action row stays without one. A received return now reads "to You"; the original sender sees it as " sent back" / "to You". --- .../Models/Chat/ChatMessage.swift | 4 ++-- .../Chat/ChatCashCardCellTests.swift | 13 +++++++++++++ FlipcashTests/Chat/SendBackTests.swift | 4 ++-- .../FlipcashUI/Chat/SendBackRowView.swift | 19 +++++++++++++++++-- 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index b8c6f17ba..dc418b041 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -538,12 +538,12 @@ public struct ChatCashContent: Hashable, Sendable, Codable { } /// A return of a DM's opening payment: "You sent back" / "to Alex" to its sender, "Alex sent - /// back" / "to you" to its receiver. An unknown name reads "Someone". + /// back" / "to You" to its receiver. An unknown name reads "Someone". public static func sentBack(isFromSelf: Bool, counterpartName: String) -> Lines { let name = counterpartName.isEmpty ? "Someone" : counterpartName return isFromSelf ? .init(caption: "You sent back", counterpart: "to \(name)") - : .init(caption: "\(name) sent back", counterpart: "to you") + : .init(caption: "\(name) sent back", counterpart: "to You") } } diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index 65ee64135..af5a94c7c 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -70,6 +70,19 @@ struct ChatCashCardCellTests { #expect((measure(offered(.sentBack)) - plain).rounded() == 20) } + @Test("Only the Sent Back footer draws its top divider") + func sendBackRow_dividerOnFooterOnly() { + let row = SendBackRowView() + row.apply(.available) + #expect(!row.isDividerVisible) + row.apply(.sending) + #expect(!row.isDividerVisible) + row.apply(.sentBack) + #expect(row.isDividerVisible) + row.apply(.available) + #expect(!row.isDividerVisible) + } + @Test("A cell recycled from an offered card to a plain one drops the row") func reusedCell_sendBackToPlain_measuresFresh() { let cell = ChatCashCardCell(frame: .zero) diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index 2eb760537..23317d692 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -132,11 +132,11 @@ struct SendBackCaptionTests { #expect(line?.counterpart == "to Alex Morgan") } - @Test("9: the receiver of a return reads ' sent back' / 'to you'") + @Test("9: the receiver of a return reads ' sent back' / 'to You'") func returnReceiver() { let line = lines(cash(2, from: alex, to: me, returning(1))) #expect(line?.caption == "Alex Morgan sent back") - #expect(line?.counterpart == "to you") + #expect(line?.counterpart == "to You") } @Test("A return draws no Send Back row of its own") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift index c9727a462..ae24d058e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -11,7 +11,8 @@ import SwiftUI import FlipcashCore /// The full-width row a cash card attaches to its bottom edge under a DM's opening payment: a white -/// "Send Back" action, a spinner while the return is sending, or a quiet "Sent Back" footer. The +/// "Send Back" action, a spinner while the return is sending, or a quiet "Sent Back" footer under a +/// full-width hairline. The /// card's shape mask rounds its lower corners. final class SendBackRowView: UIControl { @@ -29,12 +30,17 @@ final class SendBackRowView: UIControl { /// The card's caption style, so "Sent Back" reads as one of the card's lines. private static let footerFont = UIFont.appTextHeading private static let footerColor = ChatCashCardCell.secondaryColor + /// The footer's top edge: the app's list-row divider (`Row`'s `vSeparator`), one pixel thick. + private static let dividerColor = UIColor(Color.rowSeparator) private let label = UILabel() private let spinner = UIActivityIndicatorView(style: .medium) + private let divider = UIView() private var heightConstraint: NSLayoutConstraint! /// The state last applied; read by tests. private(set) var displayedState: ChatCashContent.SendBack = .available + /// Whether the footer's top divider shows; read by tests. + var isDividerVisible: Bool { !divider.isHidden } override init(frame: CGRect) { super.init(frame: frame) @@ -43,7 +49,11 @@ final class SendBackRowView: UIControl { spinner.color = Self.actionText spinner.hidesWhenStopped = true spinner.translatesAutoresizingMaskIntoConstraints = false - [label, spinner].forEach(addSubview) + divider.backgroundColor = Self.dividerColor + divider.isUserInteractionEnabled = false + divider.isHidden = true + divider.translatesAutoresizingMaskIntoConstraints = false + [label, spinner, divider].forEach(addSubview) isAccessibilityElement = true heightConstraint = heightAnchor.constraint(equalToConstant: Self.actionHeight) @@ -55,6 +65,10 @@ final class SendBackRowView: UIControl { label.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), spinner.centerXAnchor.constraint(equalTo: centerXAnchor), spinner.centerYAnchor.constraint(equalTo: centerYAnchor), + divider.topAnchor.constraint(equalTo: topAnchor), + divider.leadingAnchor.constraint(equalTo: leadingAnchor), + divider.trailingAnchor.constraint(equalTo: trailingAnchor), + divider.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), ]) } @@ -69,6 +83,7 @@ final class SendBackRowView: UIControl { func apply(_ state: ChatCashContent.SendBack) { displayedState = state heightConstraint.constant = Self.height(for: state) + divider.isHidden = state != .sentBack switch state { case .available: backgroundColor = Self.actionFill From 30becf6dfe64a1404d1a41825138971521de5c03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:52:51 -0600 Subject: [PATCH 2/4] fix(chat): keep the card's bottom padding above the Sent Back footer The Sent Back footer dropped the card's 16pt bottom padding, so the counterpart line sat directly on the new divider. It now sits under the padding, as the Send Back row does, so the gap above the divider matches the gap above the row. The footer adds 36 to the card's height, not 20. --- FlipcashTests/Chat/ChatCashCardCellTests.swift | 5 ++--- FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift | 2 -- FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index af5a94c7c..678e78e72 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -61,13 +61,12 @@ struct ChatCashCardCellTests { } } - @Test("The Send Back row is 44 tall under the card's padding; the Sent Back footer is 36 in place of it") + @Test("The Send Back row is 44 and the Sent Back footer 36, both under the card's bottom padding") func sendBackRow_sizes() { let plain = measure(offered(nil)) #expect((measure(offered(.available)) - plain).rounded() == 44) #expect((measure(offered(.sending)) - plain).rounded() == 44) - // 36 for the footer, less the 16 bottom padding it replaces. - #expect((measure(offered(.sentBack)) - plain).rounded() == 20) + #expect((measure(offered(.sentBack)) - plain).rounded() == 36) } @Test("Only the Sent Back footer draws its top divider") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 5252844e6..e5f80cf44 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -234,8 +234,6 @@ public final class ChatCashCardCell: ChatColumnCell { private func applySendBack(_ state: ChatCashContent.SendBack?) { sendBackRow.isHidden = state == nil if let state { sendBackRow.apply(state) } - // The Sent Back footer replaces the card's bottom padding; the action row sits under it. - counterpartToSendBackRow.constant = state == .sentBack ? 0 : -Self.verticalInset // Deactivate before activating, so the two never hold the label at once. counterpartToCardBottom.isActive = false counterpartToSendBackRow.isActive = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift index ae24d058e..0d6cbdf8a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/SendBackRowView.swift @@ -18,7 +18,7 @@ final class SendBackRowView: UIControl { /// The action row's height, Available and Sending (spec: 44). static let actionHeight: CGFloat = 44 - /// The Sent Back footer's height. It takes the place of the card's bottom padding (spec: 36). + /// The Sent Back footer's height, under the card's bottom padding like the action row (spec: 36). static let footerHeight: CGFloat = 36 static func height(for state: ChatCashContent.SendBack) -> CGFloat { From f60f4bb1b475cdf50dba5e54ee34944073bd654f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 14:56:40 -0600 Subject: [PATCH 3/4] test(chat): ordinary received cash keeps "from " beside a return's "to You" --- FlipcashTests/Chat/SendBackTests.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/FlipcashTests/Chat/SendBackTests.swift b/FlipcashTests/Chat/SendBackTests.swift index 23317d692..eccf57c9c 100644 --- a/FlipcashTests/Chat/SendBackTests.swift +++ b/FlipcashTests/Chat/SendBackTests.swift @@ -139,6 +139,12 @@ struct SendBackCaptionTests { #expect(line?.counterpart == "to You") } + @Test("Ordinary received cash keeps its own counterpart, not the return's 'to You'") + func ordinaryReceived_unchanged() { + let line = lines(cash(1, from: alex, to: me)) + #expect(line?.counterpart == "from Alex Morgan") + } + @Test("A return draws no Send Back row of its own") func returnHasNoRow() { let message = cash(2, from: alex, to: me, returning(1)) From 6dfbca8f14c6a403caf28305e7146d814e3ad8e2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 4 Nov 2026 15:14:54 -0600 Subject: [PATCH 4/4] fix(chat): animate a Send Back row that arrives on a drawn card The status check resolving, or a return landing, used to add the Send Back row or Sent Back footer with animation off, so the card snapped to its new height. A row that arrives on a card already on screen now fades in while the reflow spring grows the card. A row known when the card is first drawn, or on a reused cell, still appears with no animation. This drops ChatViewController's no-animation staging pass and its tests: the reconfigure now rides the batch spring like any other height change. Adds a test that the row and footer never widen the card: the card's width is a fixed constraint at maxWidth, and the row spans it edge to edge. --- .../Chat/ChatCashCardCellTests.swift | 54 ++++++++++++++++++ .../Chat/ChatSendBackStageTests.swift | 55 ------------------- .../FlipcashUI/Chat/ChatCashCardCell.swift | 33 ++++++++++- .../FlipcashUI/Chat/ChatViewController.swift | 39 ------------- 4 files changed, 85 insertions(+), 96 deletions(-) delete mode 100644 FlipcashTests/Chat/ChatSendBackStageTests.swift diff --git a/FlipcashTests/Chat/ChatCashCardCellTests.swift b/FlipcashTests/Chat/ChatCashCardCellTests.swift index 678e78e72..e7805c7ff 100644 --- a/FlipcashTests/Chat/ChatCashCardCellTests.swift +++ b/FlipcashTests/Chat/ChatCashCardCellTests.swift @@ -82,6 +82,60 @@ struct ChatCashCardCellTests { #expect(!row.isDividerVisible) } + /// The card's laid-out width at `maxWidth`, as the transcript sizes it. + private func cardWidth(_ message: ChatMessage, maxWidth: CGFloat) throws -> CGFloat { + let cell = ChatCashCardCell(frame: CGRect(x: 0, y: 0, width: Self.width, height: 400)) + cell.frame.size.height = measure(message, in: cell, maxWidth: maxWidth) + cell.layoutIfNeeded() + let amount = try #require(labels(in: cell.contentView).first { $0.attributedText?.string == "$5.00" }) + let card = try #require(amount.superview?.superview) + // The row spans the card edge to edge, never past it. + if let row = card.subviews.first(where: { $0 is SendBackRowView }), !row.isHidden { + #expect(row.frame.minX == 0 && row.frame.width == card.bounds.width) + } + return card.bounds.width + } + + @Test("The Send Back row and footer never widen the card past its width", arguments: [189.44, 250.0]) + func sendBackRow_keepsCardWidth(maxWidth: CGFloat) throws { + let plain = try cardWidth(offered(nil), maxWidth: maxWidth) + // Layout snaps the card to the screen's pixel grid, so 189.44 lays out as 189.33 on 3x. + #expect(abs(plain - maxWidth) <= 1 / UIScreen.main.scale) + for state in [ChatCashContent.SendBack.available, .sending, .sentBack] { + #expect(try cardWidth(offered(state), maxWidth: maxWidth) == plain) + } + } + + @Test("A row that arrives on a drawn card animates in; one known at first draw does not") + func sendBackRow_animatesOnlyLateArrivals() { + let cell = ChatCashCardCell(frame: .zero) + cell.configure(with: offered(.sentBack), maxWidth: 250) + #expect(!cell.didAnimateSendBackIn, "first draw") + cell.configure(with: offered(.sentBack), maxWidth: 250) + #expect(!cell.didAnimateSendBackIn, "same state redrawn") + + let late = ChatCashCardCell(frame: .zero) + late.configure(with: offered(nil), maxWidth: 250) + late.configure(with: offered(.available), maxWidth: 250) + #expect(late.didAnimateSendBackIn, "row arrives after the status check") + late.configure(with: offered(.sentBack), maxWidth: 250) + #expect(late.didAnimateSendBackIn, "footer replaces the row") + + late.prepareForReuse() + late.configure(with: offered(.available), maxWidth: 250) + #expect(!late.didAnimateSendBackIn, "reused cell draws fresh") + } + + @Test("Only the same message gaining a different, shown state counts as a late arrival") + func sendBackArrivesLate_rules() { + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: nil, id: "3", state: .available)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("2", nil), id: "3", state: .available)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .available), id: "3", state: nil)) + #expect(!ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .available), id: "3", state: .available)) + #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", nil), id: "3", state: .available)) + #expect(ChatCashCardCell.sendBackArrivesLate(drawn: ("3", .sending), id: "3", state: .sentBack)) + } + @Test("A cell recycled from an offered card to a plain one drops the row") func reusedCell_sendBackToPlain_measuresFresh() { let cell = ChatCashCardCell(frame: .zero) diff --git a/FlipcashTests/Chat/ChatSendBackStageTests.swift b/FlipcashTests/Chat/ChatSendBackStageTests.swift deleted file mode 100644 index b2f7b99e0..000000000 --- a/FlipcashTests/Chat/ChatSendBackStageTests.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// ChatSendBackStageTests.swift -// FlipcashTests -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import Testing -import FlipcashCore -@testable import FlipcashUI - -/// A Send Back row or footer appears without animating its bubble: the transcript applies those -/// cards' changes first, in place and with animation off, before the rest of the push. -@MainActor -@Suite("Chat Send Back stage") -struct ChatSendBackStageTests { - - private func cash(_ id: String, _ sendBack: ChatCashContent.SendBack?, sender: ChatMessage.Sender = .other) -> ChatItem { - .message(ChatMessage(id: id, content: .cash(ChatCashContent( - amount: "$5.00", token: "Cash", - lines: .init(caption: "Noah Parker sent", counterpart: "to you"), - sendBack: sendBack - )), sender: sender)) - } - - private func text(_ id: String) -> ChatItem { - .message(ChatMessage(id: id, text: "text-\(id)", sender: .me)) - } - - @Test("The status check resolving to Available stages the whole push, which reconfigures in place") - func rowAppears_stagesWholePush() { - let before = [cash("a", nil), text("b")] - let after = [cash("a", .available), text("b")] - #expect(ChatViewController.sendBackStage(from: before, to: after) == after) - } - - @Test("A return arriving with the original's footer stages the footer alone, keeping the old rows") - func footerWithArrival_stagesFooterOnly() { - let before = [cash("a", nil, sender: .me)] - let after = [cash("a", .sentBack, sender: .me), cash("r", nil)] - #expect(ChatViewController.sendBackStage(from: before, to: after) == [cash("a", .sentBack, sender: .me)]) - } - - @Test("Sending turning to Sent Back, a resize, is staged") - func sendingToSentBack_isStaged() { - #expect(ChatViewController.sendBackStage(from: [cash("a", .sending)], to: [cash("a", .sentBack)]) != nil) - } - - @Test("A push with no Send Back change stages nothing") - func otherChange_stagesNothing() { - let before = [cash("a", .available), text("b")] - let after = [cash("a", .available), text("b"), text("c")] - #expect(ChatViewController.sendBackStage(from: before, to: after) == nil) - } -} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index e5f80cf44..75bb13352 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -48,6 +48,11 @@ public final class ChatCashCardCell: ChatColumnCell { private let counterpartLabel = UILabel() private let centerStack = UIStackView() private let sendBackRow = SendBackRowView() + /// The message and Send Back state this cell last drew, so a row that arrives on a card already + /// on screen can animate in while one known at first draw does not. + private var drawnSendBack: (id: String, state: ChatCashContent.SendBack?)? + /// Whether the last configure faded the Send Back row in; read by tests. + private(set) var didAnimateSendBackIn = false /// The counterpart line's gap to the card's foot, or to the Send Back row when one is drawn. private var counterpartToCardBottom: NSLayoutConstraint! private var counterpartToSendBackRow: NSLayoutConstraint! @@ -184,6 +189,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.cancelDownloadTask() coinIcon.image = nil reactionRow.prepareForReuse() + drawnSendBack = nil } /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection @@ -207,7 +213,7 @@ public final class ChatCashCardCell: ChatColumnCell { coinIcon.kf.setImage(with: cash.iconURL) counterpartLabel.text = cash.lines?.counterpart counterpartLabel.isHidden = cash.lines == nil - applySendBack(cash.sendBack) + applySendBack(cash.sendBack, id: message.id) card.apply( fill: BubbleBackgroundView.fill(isFromSelf: message.sender == .me), @@ -229,11 +235,34 @@ public final class ChatCashCardCell: ChatColumnCell { card.alpha = isHighlighted ? 0.6 : 1 } + /// Whether `state` arrives on a card this cell has already drawn for message `id`: the same + /// message, a different Send Back state, and a row to show. A first draw, or a cell reused for + /// another message, has nothing to animate from. + static func sendBackArrivesLate( + drawn: (id: String, state: ChatCashContent.SendBack?)?, + id: String, + state: ChatCashContent.SendBack? + ) -> Bool { + guard let drawn, drawn.id == id, let state else { return false } + return drawn.state != state + } + /// Shows the Send Back row for `state`, or none. Read from the message on every configure, so a /// reused cell never keeps another row's spinner. - private func applySendBack(_ state: ChatCashContent.SendBack?) { + /// + /// A row or footer that arrives on a card already drawn (the status check resolving, a return + /// landing) fades in while the transcript's reflow spring grows the card to fit it. One known + /// when the card is first drawn appears with no animation. + private func applySendBack(_ state: ChatCashContent.SendBack?, id: String) { + let arrivesLate = Self.sendBackArrivesLate(drawn: drawnSendBack, id: id, state: state) + drawnSendBack = (id, state) + didAnimateSendBackIn = arrivesLate sendBackRow.isHidden = state == nil if let state { sendBackRow.apply(state) } + if arrivesLate { + UIView.performWithoutAnimation { sendBackRow.alpha = 0 } + ChatMotion.reflow.animate { [sendBackRow] in sendBackRow.alpha = 1 } + } // Deactivate before activating, so the two never hold the label at once. counterpartToCardBottom.isActive = false counterpartToSendBackRow.isActive = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b3f38feb4..2c0bac47c 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -540,13 +540,6 @@ public final class ChatViewController: UICollectionViewController { return } - // A Send Back row or footer appears without animating its bubble (send-back spec): those - // cards change first, in a pass of their own with animation off, and anything else in this - // push (the return message arriving, say) then rides the spring as usual. - if let staged = Self.sendBackStage(from: items, to: newItems) { - applyWithoutAnimation(staged) - } - let changeset = StagedChangeset(source: items, target: newItems) guard !changeset.isEmpty else { items = newItems @@ -687,38 +680,6 @@ public final class ChatViewController: UICollectionViewController { /// - Parameter old: the rows before the update. When the update only removes the group-ready /// card, the rows below slide into its space on `inviteCardExit`: an arrival's bounce would read /// as the conversation lurching rather than settling. - /// `old` with every cash card whose Send Back state differs in `new` swapped for its `new` - /// version, or `nil` when no card's Send Back state changed. Same rows in the same order as - /// `old`, so applying it is an in-place reconfigure only. - static func sendBackStage(from old: [ChatItem], to new: [ChatItem]) -> [ChatItem]? { - guard !old.isEmpty else { return nil } - let newByID = Dictionary(new.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - var changed = false - let staged = old.map { item -> ChatItem in - guard case .message(let before) = item, case .cash(let beforeCash) = before.content, - let next = newByID[item.id], case .message(let after) = next, case .cash(let afterCash) = after.content, - beforeCash.sendBack != afterCash.sendBack - else { return item } - changed = true - return next - } - return changed ? staged : nil - } - - /// Reconfigures rows in place with UIKit animation off, so a resized card snaps to its new height. - private func applyWithoutAnimation(_ staged: [ChatItem]) { - let changeset = StagedChangeset(source: items, target: staged) - guard !changeset.isEmpty else { return } - UIView.performWithoutAnimation { - collectionView.reload( - using: changeset, - reconfiguring: { [weak self] in self?.isReconfiguring = $0 }, - setData: { [weak self] data in self?.items = data } - ) - collectionView.layoutIfNeeded() - } - } - static func batchSpring(for changeset: StagedChangeset<[ChatItem]>, from old: [ChatItem] = []) -> ChatSpring { if removesOnlyInviteCard(changeset, from: old) { return ChatMotion.inviteCardExit } let changesRows = changeset.contains { stage in