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
6 changes: 3 additions & 3 deletions FlipcashTests/Chat/ChatBubbleViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion FlipcashTests/Chat/ChatQuoteBubbleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -18,20 +21,35 @@ 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,
verticalFittingPriority: .fittingSizeLevel
)
cell.frame = CGRect(x: 0, y: 0, width: 320, height: fitted.height)
cell.layoutIfNeeded()
return cell
}

@Test("A quote makes the bubble taller")
Expand All @@ -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?
Expand Down Expand Up @@ -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")
Expand Down
36 changes: 26 additions & 10 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand Down
60 changes: 48 additions & 12 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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),

Expand Down Expand Up @@ -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)
Expand Down
36 changes: 26 additions & 10 deletions FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Expand Down
Loading