From f658f9a0684b947edcf4f8d92b5f4379927f06dd Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:03:12 -0400 Subject: [PATCH 1/4] fix(chat): nest the reply quote concentrically in its bubble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quote panel's 8pt corner sat inside the bubble's 12 with a 12pt gap down the sides and 9 at the top: neither arc parallel to the other, and two different gaps to read it against. Derive the radius from a single surround instead. The panel is inset 9 on the top, leading and trailing edges — the bubble's own vertical margin — and takes the bubble's radius less that gap, so the distance between the two corners stays constant through the turn. The gap under the panel becomes the same 9, putting the quote, the body and the bubble's edges on one rhythm. Both bubbles pinned the panel's top with a literal 9 and its sides with the constant; both now read the constant on all three sides, so the surround can't drift away from the radius derived from it. --- .../FlipcashUI/Chat/ChatBubbleView.swift | 6 ++--- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 23 +++++++++++++------ .../FlipcashUI/Chat/LinkableBubbleView.swift | 6 ++--- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index fe6153210..48ff6fb04 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -67,9 +67,9 @@ public final class ChatBubbleView: UIView { background.leadingAnchor.constraint(equalTo: leadingAnchor), background.trailingAnchor.constraint(equalTo: trailingAnchor), - quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: 9), - quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.horizontalInset), - quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.horizontalInset), + quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: ChatQuotePanelView.surroundInset), + quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.surroundInset), + quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.surroundInset), labelTopToBubble, quoteZeroHeight, label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 7251e38eb..0e8e21abc 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -33,11 +33,20 @@ final class ChatQuotePanelView: UIView { /// the amount stretching and pushing the token to the far side of the panel. private let detailSpacer = UIView() - /// The panel's own inset from the bubble's edges — the body's leading inset, so the quote's - /// rule and the text below it share one margin. - static let horizontalInset: CGFloat = 12 - /// Gap between the panel and the body beneath it. - static let bottomSpacing: CGFloat = 6 + /// The gap between the panel and the bubble's edges, the same on the top, leading and trailing + /// sides — the bubble's own vertical margin. One gap rather than three, so ``cornerRadius`` + /// has a single number to be concentric with. + static let surroundInset: CGFloat = 9 + /// Gap between the panel and the body beneath it: the surround again, so the quote sits on one + /// rhythm — equal space over it, under it, and below the body. + static let bottomSpacing: CGFloat = surroundInset + + /// Concentric with the bubble: an inner corner whose arc is the outer one less the gap between + /// them keeps that gap constant all the way round the turn. Matching the bubble's radius + /// outright bulges the panel's corner into the space; a tighter one pinches it. + private static let cornerRadius = BubbleBackgroundView.baseRadius - surroundInset + + private static let ruleWidth: CGFloat = 3 /// Sized to the cap height of the amount beside it, so the flag reads as a mark on the line /// rather than as a second element the line has to make room for. @@ -59,7 +68,7 @@ final class ChatQuotePanelView: UIView { private static let cellTint: CGFloat = 0.14 private func setUp() { - layer.cornerRadius = 8 + layer.cornerRadius = Self.cornerRadius layer.cornerCurve = .continuous clipsToBounds = true @@ -105,7 +114,7 @@ final class ChatQuotePanelView: UIView { rule.leadingAnchor.constraint(equalTo: leadingAnchor), rule.topAnchor.constraint(equalTo: topAnchor), rule.bottomAnchor.constraint(equalTo: bottomAnchor), - rule.widthAnchor.constraint(equalToConstant: 3), + rule.widthAnchor.constraint(equalToConstant: Self.ruleWidth), authorLabel.leadingAnchor.constraint(equalTo: rule.trailingAnchor, constant: 8), authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 92ff3749f..2eddb5728 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -82,9 +82,9 @@ public final class LinkableBubbleView: UIView { background.leadingAnchor.constraint(equalTo: leadingAnchor), background.trailingAnchor.constraint(equalTo: trailingAnchor), - quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: 9), - quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.horizontalInset), - quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.horizontalInset), + quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: ChatQuotePanelView.surroundInset), + quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.surroundInset), + quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.surroundInset), textTopToBubble, quoteZeroHeight, textView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), From 798dab64d0798312b813bd36119cdd6adaf8d0bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:03:13 -0400 Subject: [PATCH 2/4] fix(tests): stop the edited-marker helper shadowing itself `let marker = try #require(marker(in: bubble))` reads as a call to the helper, but the #require expansion resolves `marker` to the UILabel the line is declaring. FlipcashTests failed to compile with 17 copies of "cannot call value of non-function type 'UILabel'", which took the whole target down with it. Rename the helper to editedMarker(in:). --- FlipcashTests/Chat/ChatBubbleViewTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/FlipcashTests/Chat/ChatBubbleViewTests.swift b/FlipcashTests/Chat/ChatBubbleViewTests.swift index 0b2ca42d1..e10709b4f 100644 --- a/FlipcashTests/Chat/ChatBubbleViewTests.swift +++ b/FlipcashTests/Chat/ChatBubbleViewTests.swift @@ -139,14 +139,14 @@ struct ChatBubbleDeletedTests { return cell.bubbleView } - private func marker(in bubble: ChatBubbleView) -> UILabel? { + private func editedMarker(in bubble: ChatBubbleView) -> UILabel? { bubble.subviews.compactMap { $0 as? UILabel }.first { $0.text == EditedMarker.text } } @Test("The marker sits in the bubble's bottom-trailing corner") func markerSitsInTheCorner() throws { let bubble = laidOutBubble(text: "hello", isEdited: true) - let marker = try #require(marker(in: bubble)) + let marker = try #require(editedMarker(in: bubble)) #expect(!marker.isHidden) #expect(abs(marker.frame.maxX - (bubble.bounds.width - EditedMarker.trailingInset)) < 0.5) @@ -174,7 +174,7 @@ struct ChatBubbleDeletedTests { let plain = laidOutBubble(text: body, isEdited: false) let edited = laidOutBubble(text: body, isEdited: true) - let marker = try #require(marker(in: edited)) + let marker = try #require(editedMarker(in: edited)) #expect(abs(plain.bounds.height - oneLine) < 0.5) // the body itself still fits one line #expect(edited.bounds.height > plain.bounds.height) // the marker took a line of its own From 1fbd25c68dd2c63733b4d7697ff42956f2bff6b8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 13:21:51 -0400 Subject: [PATCH 3/4] fix(chat): drop the quote's width when a bubble is reused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bubbles holding the same short message came back at three different widths. The cell recycles its bubble, and a bubble with no quote hides the panel and pins it to zero height — but `isHidden` only skips drawing. The panel is still pinned to both of the bubble's sides, so the author name and snippet left over from the reply that cell last held went on demanding their width, and the bubble stayed as wide as that quote. A recycled bubble measured 155pt wider than a fresh one holding the same "hi". Empty the panel when the message has no quote, so there is no text left to ask for the space. --- FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 29 ++++++++++++++++++- .../FlipcashUI/Chat/ChatBubbleView.swift | 1 + .../FlipcashUI/Chat/ChatQuotePanelView.swift | 16 ++++++++++ .../FlipcashUI/Chat/LinkableBubbleView.swift | 1 + 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index ff61e3c1d..e96f7a63c 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -24,6 +24,13 @@ struct ChatQuoteBubbleTests { with: ChatMessage(id: "1", content: .text("works"), sender: .me, quote: quote), maxWidth: Self.maxWidth ) + layOut(cell) + return cell + } + + /// Sizes the cell to the height its content asks for and lays it out, so the bubble's frame is + /// the one the transcript would draw. + private func layOut(_ cell: ChatMessageCell) { let fitted = cell.contentView.systemLayoutSizeFitting( CGSize(width: 320, height: 0), withHorizontalFittingPriority: .required, @@ -31,7 +38,6 @@ struct ChatQuoteBubbleTests { ) cell.frame = CGRect(x: 0, y: 0, width: 320, height: fitted.height) cell.layoutIfNeeded() - return cell } @Test("A quote makes the bubble taller") @@ -56,6 +62,27 @@ struct ChatQuoteBubbleTests { #expect(cell.bubbleView.quotePanel.isHidden) } + @Test("Reusing a bubble drops the quote's width with it") + func reuse_dropsTheQuotesWidth() { + let wide = ChatQuote( + stableID: "7", + authorName: "Ada", + snippet: String(repeating: "wide ", count: 8), + kind: .text + ) + let plain = ChatMessage(id: "2", content: .text("hi"), sender: .me) + + let recycled = laidOutCell(quote: wide) + recycled.configure(with: plain, maxWidth: Self.maxWidth) + layOut(recycled) + + let fresh = ChatMessageCell(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) + fresh.configure(with: plain, maxWidth: Self.maxWidth) + layOut(fresh) + + #expect(abs(recycled.bubbleView.frame.width - fresh.bubbleView.frame.width) < 0.5) + } + @Test("The panel reports the row it jumps to") func panel_reportsItsTarget() { var tapped: String? diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 48ff6fb04..be4fe4b25 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -101,6 +101,7 @@ public final class ChatBubbleView: UIView { labelTopToQuote.isActive = true } else { quotePanel.isHidden = true + quotePanel.clear() labelTopToQuote.isActive = false labelTopToBubble.isActive = true quoteZeroHeight.isActive = true diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 0e8e21abc..b7bd724a1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -174,6 +174,22 @@ final class ChatQuotePanelView: UIView { : "Replying to \(quote.authorName): \(spoken)" } + /// Empties the panel for a message that has no quote. Hiding it is not enough: `isHidden` only + /// skips drawing, and the panel is still pinned to both of the bubble's sides, so a recycled + /// cell's stale author name and snippet go on demanding their width and the bubble stays as + /// wide as the reply it used to hold. + func clear() { + targetStableID = nil + authorLabel.text = nil + snippetLabel.text = nil + tokenLabel.text = nil + flagView.image = nil + flagView.isHidden = true + tokenLabel.isHidden = true + isUserInteractionEnabled = false + accessibilityLabel = nil + } + @objc private func handleTap() { guard let targetStableID else { return } onTap?(targetStableID) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 2eddb5728..ec0e8cb8d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -122,6 +122,7 @@ public final class LinkableBubbleView: UIView { textTopToQuote.isActive = true } else { quotePanel.isHidden = true + quotePanel.clear() textTopToQuote.isActive = false textTopToBubble.isActive = true quoteZeroHeight.isActive = true From a160d99e48fe6fb1d0cedd4c22a8266e492d922e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 8 Sep 2026 14:12:18 -0400 Subject: [PATCH 4/4] fix(chat): stop a quote-less bubble paying for the quote panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every bubble had a 42pt floor under its width, so a one- or two-character message came out as wide as a short phrase. The quote panel is pinned to both of the bubble's sides, and the rule, its two gutters and the detail row's spacing add up to 24pt the panel demands with nothing in it; the bubble's own 18pt of insets make the rest. Collapsing the panel's height when there is no quote left that width in place. Collapse both axes instead, and demote the panel's internal horizontal chain to `.required - 1` so the zero-width constraint can break it. The trailing pin goes with it: an equality against a collapsed panel pulls the bubble's trailing edge inward rather than outward, squeezing the body to nothing, so it is active only alongside a quote. `noQuote_hugsItsText` measures ".", "hi" and "Hello there" against the text's own width plus the bubble's padding, and `reuse_hugsItsOwnText` does the same for a recycled bubble — both failed by the 14pt the floor added. --- FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 44 ++++++++++++++++--- .../FlipcashUI/Chat/ChatBubbleView.swift | 31 +++++++++---- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 23 +++++++--- .../FlipcashUI/Chat/LinkableBubbleView.swift | 31 +++++++++---- 4 files changed, 100 insertions(+), 29 deletions(-) diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index e96f7a63c..d4d0ceb33 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -10,6 +10,9 @@ import UIKit import FlipcashCore @testable import FlipcashUI +/// `ChatBubbleView`'s horizontal padding, 12 on each side of the body. +private let bodyPadding: CGFloat = 24 + @Suite("Reply bubble quote panel") @MainActor struct ChatQuoteBubbleTests { @@ -18,6 +21,15 @@ struct ChatQuoteBubbleTests { private let quote = ChatQuote(stableID: "7", authorName: "Ada", snippet: "dinner at 7?", kind: .text) + /// A quote wide enough to push the bubble to `maxWidth`, so a bubble that keeps any of it after + /// reuse is off by a margin no rounding can explain. + private static let wideQuote = ChatQuote( + stableID: "7", + authorName: "Ada", + snippet: String(repeating: "wide ", count: 8), + kind: .text + ) + private func laidOutCell(quote: ChatQuote?) -> ChatMessageCell { let cell = ChatMessageCell(frame: CGRect(x: 0, y: 0, width: 320, height: 80)) cell.configure( @@ -64,15 +76,9 @@ struct ChatQuoteBubbleTests { @Test("Reusing a bubble drops the quote's width with it") func reuse_dropsTheQuotesWidth() { - let wide = ChatQuote( - stableID: "7", - authorName: "Ada", - snippet: String(repeating: "wide ", count: 8), - kind: .text - ) let plain = ChatMessage(id: "2", content: .text("hi"), sender: .me) - let recycled = laidOutCell(quote: wide) + let recycled = laidOutCell(quote: Self.wideQuote) recycled.configure(with: plain, maxWidth: Self.maxWidth) layOut(recycled) @@ -83,6 +89,18 @@ struct ChatQuoteBubbleTests { #expect(abs(recycled.bubbleView.frame.width - fresh.bubbleView.frame.width) < 0.5) } + @Test("A recycled bubble is only as wide as the text it now holds") + func reuse_hugsItsOwnText() { + let plain = ChatMessage(id: "2", content: .text("hi"), sender: .me) + + let recycled = laidOutCell(quote: Self.wideQuote) + recycled.configure(with: plain, maxWidth: Self.maxWidth) + layOut(recycled) + + let body = ChatBubbleView.displayText(for: plain)?.size().width ?? 0 + #expect(abs(recycled.bubbleView.frame.width - (body + bodyPadding)) < 1) + } + @Test("The panel reports the row it jumps to") func panel_reportsItsTarget() { var tapped: String? @@ -190,6 +208,18 @@ struct ChatQuoteBubbleGeometryTests { #expect(abs(plain.bubbleFrame.maxX - quoted.bubbleFrame.maxX) < 0.5) } + @Test("A bubble with no quote is only as wide as its text") + func noQuote_hugsItsText() { + for text in [".", "hi", "Hello there"] { + let message = ChatMessage(id: "1", content: .text(text), sender: .me) + let bubble = ChatBubbleView() + bubble.configure(with: message) + let fitted = bubble.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize).width + let body = ChatBubbleView.displayText(for: message)?.size().width ?? 0 + #expect(abs(fitted - (body + bodyPadding)) < 1, "\(text)") + } + } + @Test("A quote wider than the body widens the bubble to hold it") func longQuote_widensTheBubble() { let narrow = laidOutCell(sender: .me, quote: nil, text: "ok") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index be4fe4b25..7581c10d6 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -30,8 +30,15 @@ public final class ChatBubbleView: UIView { private var labelTopToBubble: NSLayoutConstraint! /// Body pinned below the quote panel, for a reply. private var labelTopToQuote: NSLayoutConstraint! - /// Collapses the panel when there is no quote, so a hidden view consumes no height. - private var quoteZeroHeight: NSLayoutConstraint! + /// Collapses the panel to nothing when there is no quote, in both axes. Height alone is not + /// enough: the panel is pinned to both of the bubble's sides, so whatever width it demands with + /// nothing in it — the rule and its gutters — becomes a floor under every bubble's width, and a + /// one-character message comes out as wide as a two-word one. + private var quoteCollapse: [NSLayoutConstraint] = [] + /// Stretches the panel to the bubble's trailing edge, and so carries a wide quote's width out to + /// the bubble. Live only alongside a quote: against a collapsed panel the same equality pulls the + /// *other* way and squeezes the bubble down onto the panel's zero width. + private var quoteTrailing: NSLayoutConstraint! public override init(frame: CGRect) { super.init(frame: frame) @@ -59,9 +66,17 @@ public final class ChatBubbleView: UIView { equalTo: quotePanel.bottomAnchor, constant: ChatQuotePanelView.bottomSpacing ) - quoteZeroHeight = quotePanel.heightAnchor.constraint(equalToConstant: 0) + quoteCollapse = [ + quotePanel.heightAnchor.constraint(equalToConstant: 0), + quotePanel.widthAnchor.constraint(equalToConstant: 0), + ] + + quoteTrailing = quotePanel.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -ChatQuotePanelView.surroundInset + ) - NSLayoutConstraint.activate([ + NSLayoutConstraint.activate(quoteCollapse + [ background.topAnchor.constraint(equalTo: topAnchor), background.bottomAnchor.constraint(equalTo: bottomAnchor), background.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -69,9 +84,7 @@ public final class ChatBubbleView: UIView { quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: ChatQuotePanelView.surroundInset), quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.surroundInset), - quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.surroundInset), labelTopToBubble, - quoteZeroHeight, label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), @@ -96,7 +109,8 @@ public final class ChatBubbleView: UIView { if let quote = message.quote { quotePanel.isHidden = false quotePanel.configure(with: quote) - quoteZeroHeight.isActive = false + NSLayoutConstraint.deactivate(quoteCollapse) + quoteTrailing.isActive = true labelTopToBubble.isActive = false labelTopToQuote.isActive = true } else { @@ -104,7 +118,8 @@ public final class ChatBubbleView: UIView { quotePanel.clear() labelTopToQuote.isActive = false labelTopToBubble.isActive = true - quoteZeroHeight.isActive = true + quoteTrailing.isActive = false + NSLayoutConstraint.activate(quoteCollapse) } background.apply( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index b7bd724a1..806e03269 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -108,20 +108,31 @@ final class ChatQuotePanelView: UIView { detailRow.addArrangedSubview(detailSpacer) addSubview(detailRow) - NSLayoutConstraint.activate([ + // The rule, the two gutters around the text and the detail row's spacing add up to a width + // the panel demands even when it holds nothing, and a bubble with no quote would pay for it: + // the host pins the panel to both of the bubble's sides, so the panel's floor becomes the + // bubble's. They sit a step under required so the host's collapse can break them and take + // the floor to zero — see ``ChatQuotePanelView`` in `ChatBubbleView.setUp()`. + let horizontal = [ + rule.widthAnchor.constraint(equalToConstant: Self.ruleWidth), + authorLabel.leadingAnchor.constraint(equalTo: rule.trailingAnchor, constant: 8), + authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + detailRow.leadingAnchor.constraint(equalTo: authorLabel.leadingAnchor), + detailRow.trailingAnchor.constraint(equalTo: authorLabel.trailingAnchor), + ] + for constraint in horizontal { + constraint.priority = .required - 1 + } + + NSLayoutConstraint.activate(horizontal + [ // Flush against the cell's leading edge and the full height of it, so the cell reads as // a quote rather than a card with a line drawn near it. The corner radius clips it. rule.leadingAnchor.constraint(equalTo: leadingAnchor), rule.topAnchor.constraint(equalTo: topAnchor), rule.bottomAnchor.constraint(equalTo: bottomAnchor), - rule.widthAnchor.constraint(equalToConstant: Self.ruleWidth), - authorLabel.leadingAnchor.constraint(equalTo: rule.trailingAnchor, constant: 8), - authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), authorLabel.topAnchor.constraint(equalTo: topAnchor, constant: 6), - detailRow.leadingAnchor.constraint(equalTo: authorLabel.leadingAnchor), - detailRow.trailingAnchor.constraint(equalTo: authorLabel.trailingAnchor), detailRow.topAnchor.constraint(equalTo: authorLabel.bottomAnchor, constant: 1), detailRow.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -6), diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index ec0e8cb8d..5e882a7a9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -33,8 +33,15 @@ public final class LinkableBubbleView: UIView { private var textTopToBubble: NSLayoutConstraint! /// Body pinned below the quote panel, for a reply. private var textTopToQuote: NSLayoutConstraint! - /// Collapses the panel when there is no quote, so a hidden view consumes no height. - private var quoteZeroHeight: NSLayoutConstraint! + /// Collapses the panel to nothing when there is no quote, in both axes. Height alone is not + /// enough: the panel is pinned to both of the bubble's sides, so whatever width it demands with + /// nothing in it — the rule and its gutters — becomes a floor under every bubble's width, and a + /// one-character message comes out as wide as a two-word one. + private var quoteCollapse: [NSLayoutConstraint] = [] + /// Stretches the panel to the bubble's trailing edge, and so carries a wide quote's width out to + /// the bubble. Live only alongside a quote: against a collapsed panel the same equality pulls the + /// *other* way and squeezes the bubble down onto the panel's zero width. + private var quoteTrailing: NSLayoutConstraint! public override init(frame: CGRect) { super.init(frame: frame) @@ -74,9 +81,17 @@ public final class LinkableBubbleView: UIView { equalTo: quotePanel.bottomAnchor, constant: ChatQuotePanelView.bottomSpacing ) - quoteZeroHeight = quotePanel.heightAnchor.constraint(equalToConstant: 0) + quoteCollapse = [ + quotePanel.heightAnchor.constraint(equalToConstant: 0), + quotePanel.widthAnchor.constraint(equalToConstant: 0), + ] + + quoteTrailing = quotePanel.trailingAnchor.constraint( + equalTo: trailingAnchor, + constant: -ChatQuotePanelView.surroundInset + ) - NSLayoutConstraint.activate([ + NSLayoutConstraint.activate(quoteCollapse + [ background.topAnchor.constraint(equalTo: topAnchor), background.bottomAnchor.constraint(equalTo: bottomAnchor), background.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -84,9 +99,7 @@ public final class LinkableBubbleView: UIView { quotePanel.topAnchor.constraint(equalTo: topAnchor, constant: ChatQuotePanelView.surroundInset), quotePanel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: ChatQuotePanelView.surroundInset), - quotePanel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -ChatQuotePanelView.surroundInset), textTopToBubble, - quoteZeroHeight, textView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), textView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), textView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), @@ -117,7 +130,8 @@ public final class LinkableBubbleView: UIView { if let quote = message.quote { quotePanel.isHidden = false quotePanel.configure(with: quote) - quoteZeroHeight.isActive = false + NSLayoutConstraint.deactivate(quoteCollapse) + quoteTrailing.isActive = true textTopToBubble.isActive = false textTopToQuote.isActive = true } else { @@ -125,7 +139,8 @@ public final class LinkableBubbleView: UIView { quotePanel.clear() textTopToQuote.isActive = false textTopToBubble.isActive = true - quoteZeroHeight.isActive = true + quoteTrailing.isActive = false + NSLayoutConstraint.activate(quoteCollapse) } background.apply(