From 4b64decf56d3aac22c94cd99c951af3ec78d381b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:38:16 -0400 Subject: [PATCH 1/2] fix(chat): size the cash card to the shared bubble width The cash card pinned itself at a fixed 232pt, out of step with text and link bubbles, which cap at 0.78 of the transcript width. Card and reaction row now take the same maxWidth the owner already computes for those cells. --- .../FlipcashUI/Chat/ChatCashCardCell.swift | 37 ++++++++++++------- .../FlipcashUI/Chat/ChatViewController.swift | 2 +- .../NotificationTranscriptView.swift | 3 +- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 1afc7b8dd..607a4e46d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -10,16 +10,18 @@ import UIKit import FlipcashCore import Kingfisher -/// A recycled cell for a cash payment row: a fixed-size card (shared `BubbleBackgroundView` chrome) -/// with the token name + optional coin icon in the top-left and a centered "You sent / You received" -/// caption above the currency flag + amount, hosted in a `ChatColumnCell`. Dumb — `configure(with:)` -/// is the only input; the strings, flag name, and icon URL all arrive on the `ChatMessage`. +/// A recycled cell for a cash payment row: a card (shared `BubbleBackgroundView` chrome) with the +/// token name + optional coin icon in the top-left and a centered "You sent / You received" caption +/// above the currency flag + amount, hosted in a `ChatColumnCell`. The card is capped at a *constant* +/// max width supplied by the owner — same as `ChatMessageCell`/`ChatLinkMessageCell` — so it tracks +/// the shared bubble width instead of a fixed footprint. `configure(with:maxWidth:)` is the only +/// input; the strings, flag name, and icon URL all arrive on the `ChatMessage`. public final class ChatCashCardCell: ChatColumnCell { public static let reuseIdentifier = "ChatCashCardCell" - /// The card's fixed footprint. - static let cardSize = CGSize(width: 232, height: 170) + /// The card's fixed height; width now tracks `maxWidth`. + static let cardHeight: CGFloat = 170 private let card = BubbleBackgroundView() private let reactionRow = ReactionPillRowView() @@ -28,6 +30,8 @@ public final class ChatCashCardCell: ChatColumnCell { private let flag = UIImageView() private let captionLabel = UILabel() private let amountLabel = UILabel() + private var cardWidthConstraint: NSLayoutConstraint! + private var reactionRowWidthConstraint: NSLayoutConstraint! /// Dim the card while pressed so the tap-to-open-currency-info affordance reads as a button. /// Driven by the collection view's selection machinery (`shouldHighlightItemAt`), not a gesture. @@ -83,12 +87,15 @@ public final class ChatCashCardCell: ChatColumnCell { reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } // Below required so the card height yields to the cell's self-sizing height instead of fighting it. - let cardHeight = card.heightAnchor.constraint(equalToConstant: Self.cardSize.height) + let cardHeight = card.heightAnchor.constraint(equalToConstant: Self.cardHeight) cardHeight.priority = UILayoutPriority(999) + cardWidthConstraint = card.widthAnchor.constraint(equalToConstant: 232) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: 232) + NSLayoutConstraint.activate([ - card.widthAnchor.constraint(equalToConstant: Self.cardSize.width), - reactionRow.widthAnchor.constraint(equalToConstant: Self.cardSize.width), + cardWidthConstraint, + reactionRowWidthConstraint, cardHeight, tokenRow.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 11), @@ -104,7 +111,7 @@ public final class ChatCashCardCell: ChatColumnCell { @available(*, unavailable) public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - /// Scopes the row's touch target to the fixed-width card. The cell spans the full transcript + /// Scopes the row's touch target to the card, not the cell. The cell spans the full transcript /// width, so without this the selection machinery opens currency info from a tap in the dead /// space beside the card — the same reason the retry gesture on text rows hugs the bubble. public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { @@ -136,8 +143,12 @@ public final class ChatCashCardCell: ChatColumnCell { reactionRow.prepareForReuse() } - public func configure(with message: ChatMessage, authorImageData: Data? = nil) { + /// - Parameter maxWidth: the widest the card may grow, in points. The owner derives it from the + /// collection view's width, the same fraction it uses for text bubbles. + public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { guard case .cash(let cash) = message.content else { return } + cardWidthConstraint.constant = maxWidth + reactionRowWidthConstraint.constant = maxWidth tokenLabel.text = cash.token captionLabel.text = ChatCashContent.caption(isFromSelf: message.sender == .me, isTip: cash.isTip) amountLabel.text = cash.amount @@ -158,7 +169,7 @@ public final class ChatCashCardCell: ChatColumnCell { ), identity: message.id ) - reactionRow.layoutWidth = Self.cardSize.width + reactionRow.layoutWidth = maxWidth reactionRow.hugsTrailingEdge = message.sender == .me reactionRow.configure(pills: message.reactions, canReact: message.canReact) updateColumn(for: message, authorImageData: authorImageData) @@ -215,7 +226,7 @@ private final class ChatCashCardCellPreviewController: UICollectionViewControlle override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ChatCashCardCell.reuseIdentifier, for: indexPath) as! ChatCashCardCell - cell.configure(with: messages[indexPath.item]) + cell.configure(with: messages[indexPath.item], maxWidth: collectionView.bounds.width * 0.78) return cell } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index be52dd6bc..9b1325daf 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -509,7 +509,7 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatCashCardCell: - cell.configure(with: message, authorImageData: authorImageData) + cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index c806499bd..3d8916a43 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -127,7 +127,8 @@ private struct NotificationTextBubble: View { } } -/// A payment "cash card" matching `ChatCashCardCell` (232×170): a token row top-left, a centered +/// A payment "cash card" styled like `ChatCashCardCell`, fixed at 232×170 here (there is no +/// transcript width to size off in a notification banner): a token row top-left, a centered /// "You sent / You received" caption over the currency flag + amount, and the same bubble chrome. private struct NotificationCashCard: View { From 54167c64754b688d7126e1d9c30ca572b54f777f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:33:56 -0400 Subject: [PATCH 2/2] fix(chat): size the cash card to 0.64 of the inset row The cash card now takes 0.64 of the row between the column insets instead of the 0.78 bubble fraction, matching Android's cash card. The reaction row under it follows the same width. --- .../Sources/FlipcashUI/Chat/ChatCashCardCell.swift | 14 +++++++------- .../FlipcashUI/Chat/ChatViewController.swift | 8 +++++++- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift index 607a4e46d..e30eb6e7e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatCashCardCell.swift @@ -12,10 +12,10 @@ import Kingfisher /// A recycled cell for a cash payment row: a card (shared `BubbleBackgroundView` chrome) with the /// token name + optional coin icon in the top-left and a centered "You sent / You received" caption -/// above the currency flag + amount, hosted in a `ChatColumnCell`. The card is capped at a *constant* -/// max width supplied by the owner — same as `ChatMessageCell`/`ChatLinkMessageCell` — so it tracks -/// the shared bubble width instead of a fixed footprint. `configure(with:maxWidth:)` is the only -/// input; the strings, flag name, and icon URL all arrive on the `ChatMessage`. +/// above the currency flag + amount, hosted in a `ChatColumnCell`. The card's width is supplied by +/// the owner, as `ChatMessageCell`/`ChatLinkMessageCell` take theirs, so it tracks the transcript +/// width instead of a fixed footprint. `configure(with:maxWidth:)` is the only input; the strings, +/// flag name, and icon URL all arrive on the `ChatMessage`. public final class ChatCashCardCell: ChatColumnCell { public static let reuseIdentifier = "ChatCashCardCell" @@ -143,8 +143,8 @@ public final class ChatCashCardCell: ChatColumnCell { reactionRow.prepareForReuse() } - /// - Parameter maxWidth: the widest the card may grow, in points. The owner derives it from the - /// collection view's width, the same fraction it uses for text bubbles. + /// - Parameter maxWidth: the card's width, in points. The owner derives it from the collection + /// view's width: 0.64 of the row between the column insets, matching Android's cash card. public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { guard case .cash(let cash) = message.content else { return } cardWidthConstraint.constant = maxWidth @@ -226,7 +226,7 @@ private final class ChatCashCardCellPreviewController: UICollectionViewControlle override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ChatCashCardCell.reuseIdentifier, for: indexPath) as! ChatCashCardCell - cell.configure(with: messages[indexPath.item], maxWidth: collectionView.bounds.width * 0.78) + cell.configure(with: messages[indexPath.item], maxWidth: (collectionView.bounds.width - 24) * 0.64) return cell } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 9b1325daf..9a210786d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -103,6 +103,11 @@ public final class ChatViewController: UICollectionViewController { /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 + /// A cash card's width as a fraction of the row between the column insets — narrower than a + /// bubble, and taken of the inset row rather than the full width. Matches Android's + /// `CASH_CARD_WIDTH_FRACTION` so the card reads the same size on both. + private static let cashCardWidthFraction: CGFloat = 0.64 + /// Avatar bytes for the transcript's authors and typists, keyed by user id. Empty in a DM, where /// no row is attributed. The owner fills it as pictures download; rows already on screen pick the /// new bytes up without a diff, since nothing about the row itself changed. @@ -509,7 +514,8 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatCashCardCell: - cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) + let cardWidth = (available - ChatColumnCell.rowInset * 2) * Self.cashCardWidthFraction + cell.configure(with: message, maxWidth: cardWidth, authorImageData: authorImageData) cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) }