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 diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index ff61e3c1d..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,12 +21,28 @@ 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( 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 +50,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 +74,33 @@ struct ChatQuoteBubbleTests { #expect(cell.bubbleView.quotePanel.isHidden) } + @Test("Reusing a bubble drops the quote's width with it") + func reuse_dropsTheQuotesWidth() { + 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 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("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? @@ -163,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 fe6153210..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,19 +66,25 @@ 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), 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), labelTopToBubble, - quoteZeroHeight, label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), @@ -96,14 +109,17 @@ 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 { quotePanel.isHidden = true + 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 7251e38eb..806e03269 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 @@ -99,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: 3), - 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), @@ -165,6 +185,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 92ff3749f..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,19 +81,25 @@ 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), 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), textTopToBubble, - quoteZeroHeight, textView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9), textView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), textView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), @@ -117,14 +130,17 @@ 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 { quotePanel.isHidden = true + quotePanel.clear() textTopToQuote.isActive = false textTopToBubble.isActive = true - quoteZeroHeight.isActive = true + quoteTrailing.isActive = false + NSLayoutConstraint.activate(quoteCollapse) } background.apply(