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
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import CoreGraphics

/// The thread line that joins an inline reply's quote to the reply, as
/// Messages draws it: it drops from under the quoted bubble's tail (or, in a
/// group, the avatar column beside the quote) and, when the reply sits on
/// the other side of the transcript, turns toward it in a short curve level
/// with the reply's middle. It never starts beside the quote or crosses it.
/// Top-left-origin coordinates, the cell's.
public struct ConversationReplyThread: Equatable, Sendable {
/// The quoted bubble's body (its tail drops `quoteTailDrop` below it).
public var quoteBody: CGRect
/// The side the quoted message was sent from (its tail's side).
public var quoteSide: ConversationBubbleGeometry.Side
public var quoteTailDrop: CGFloat
/// The reply's bubble (or its first piece of content).
public var reply: CGRect
public var replySide: ConversationBubbleGeometry.Side
/// A group's avatar column center for an incoming quote; nil threads
/// under the quote's own tail.
public var column: CGFloat?
/// Where a line on the reply's own side stops (a group reply's avatar
/// top); nil stops just above the reply.
public var stopY: CGFloat?

/// How far in from the quote's body edge the line drops (under the tail).
public static let tailInset: CGFloat = 6
/// Space between the line and what it leaves or meets.
public static let gap: CGFloat = 3
/// The turn toward a reply on the other side, and the level run after
/// it (Messages: the line ends pointing at the reply, short of it).
public static let turnRadius: CGFloat = 12
public static let run: CGFloat = 8
/// The room a row keeps for a straight line to a reply on the quote's
/// own side (between the gaps above and below it).
public static let minimumStraightRun: CGFloat = 12

public init(
quoteBody: CGRect,
quoteSide: ConversationBubbleGeometry.Side,
quoteTailDrop: CGFloat,
reply: CGRect,
replySide: ConversationBubbleGeometry.Side,
column: CGFloat? = nil,
stopY: CGFloat? = nil
) {
self.quoteBody = quoteBody
self.quoteSide = quoteSide
self.quoteTailDrop = quoteTailDrop
self.reply = reply
self.replySide = replySide
self.column = column
self.stopY = stopY
}

/// The thread for one transcript row. `avatarColumnCenter` is the
/// center of a group's avatar column; `replyAvatarTop` the top of an
/// incoming group reply's avatar.
public static func row(
quoteBody: CGRect,
quoteSide: ConversationBubbleGeometry.Side,
quoteTailDrop: CGFloat,
reply: CGRect,
replySide: ConversationBubbleGeometry.Side,
isGroup: Bool,
avatarColumnCenter: CGFloat,
replyAvatarTop: CGFloat?
) -> ConversationReplyThread {
ConversationReplyThread(
quoteBody: quoteBody, quoteSide: quoteSide, quoteTailDrop: quoteTailDrop,
reply: reply, replySide: replySide,
// A group threads an incoming quote through the avatar column
// beside it; otherwise the line drops from under the quote's tail.
column: isGroup && quoteSide == .leading ? avatarColumnCenter : nil,
stopY: replyAvatarTop.map { $0 - 6 }
)
}

/// The x the line drops along.
public var columnX: CGFloat {
column ?? (quoteSide == .leading ? quoteBody.minX + Self.tailInset : quoteBody.maxX - Self.tailInset)
}

/// The line's top: under the quote and its tail.
public var startY: CGFloat {
quoteBody.maxY + (column == nil ? quoteTailDrop : 0) + Self.gap
}

/// The line's bottom: level with the reply's middle when it turns toward
/// a reply on the other side, else just above the reply (or `stopY`).
public var endY: CGFloat {
let end = turnsTowardReply ? reply.midY : (stopY ?? reply.minY - Self.gap)
return max(startY + 1, end)
}

/// The reply sits on the other side: the line ends in a turn toward it.
public var turnsTowardReply: Bool { quoteSide != replySide }

public var path: CGPath {
let x = columnX
let start = startY
let end = endY
let path = CGMutablePath()
path.move(to: CGPoint(x: x, y: start))
guard turnsTowardReply else {
path.addLine(to: CGPoint(x: x, y: end))
return path
}
let direction: CGFloat = quoteSide == .leading ? 1 : -1
let radius = min(Self.turnRadius, end - start)
path.addLine(to: CGPoint(x: x, y: end - radius))
path.addQuadCurve(to: CGPoint(x: x + direction * radius, y: end), control: CGPoint(x: x, y: end))
path.addLine(to: CGPoint(x: x + direction * (radius + Self.run), y: end))
return path
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,13 @@ extension MessageCellLayout {
return (value * scale).rounded() / scale
}

/// How far the quote's tail drops below it: the quote draws the bubble
/// outline with its corner radius capped to half its body.
static func quoteTailDrop(quoteHeight: CGFloat, quoteBodyWidth: CGFloat) -> CGFloat {
let radius = min(ConversationTheme.bubbleCornerRadius, quoteHeight / 2, quoteBodyWidth / 2)
return ConversationBubbleGeometry.iOSTailDrop(radius: radius)
}

static func measure(_ text: NSAttributedString, maxWidth: CGFloat) -> CGSize {
let rect = text.boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
Expand Down Expand Up @@ -389,7 +396,12 @@ extension MessageCellLayout {
quoteFrame = frame
let bodyMinX = quote.isOutgoing ? frame.minX : frame.minX + t.tailWidth
quoteTextFrame = CGRect(x: bodyMinX + 12, y: y + 7, width: bodyWidth - 24, height: textHeight)
y += h + t.transcriptSpace(ConversationTranscriptMetrics.smallSpace)
// The quote's tail hangs below it, and the thread line leaves from under the tail.
// A reply on the quote's own side (outside a group's avatar column) also needs
// room for the line that drops between them.
let straightLine = quote.isOutgoing == model.isOutgoing && !(model.isGroup && !quote.isOutgoing)
y += h + Self.quoteTailDrop(quoteHeight: h, quoteBodyWidth: bodyWidth) + t.transcriptSpace(ConversationTranscriptMetrics.smallSpace)
+ (straightLine ? ConversationReplyThread.minimumStraightRun : 0)
}

var senderNameFrame: CGRect?
Expand Down Expand Up @@ -585,20 +597,23 @@ extension MessageCellLayout {

var threadPath: CGPath?
if let quoteFrame, let quote = model.replyQuote {
// A thread line on the leading side joins the quote to the reply.
let x = margin + t.avatarSize / 2
let path = UIBezierPath()
let quoteBodyLeading = quote.isOutgoing ? quoteFrame.minX : quoteFrame.minX + t.tailWidth
path.move(to: CGPoint(x: min(quoteBodyLeading - 4, x + 14), y: quoteFrame.midY))
path.addQuadCurve(to: CGPoint(x: x, y: quoteFrame.midY + 14), controlPoint: CGPoint(x: x, y: quoteFrame.midY))
if model.isOutgoing || !model.reservesAvatarColumn {
let endY = max(quoteFrame.midY + 28, primary.midY)
path.addLine(to: CGPoint(x: x, y: endY - 14))
path.addQuadCurve(to: CGPoint(x: x + 14, y: endY), controlPoint: CGPoint(x: x, y: endY))
} else {
path.addLine(to: CGPoint(x: x, y: max(quoteFrame.midY + 16, (avatarFrame?.minY ?? primary.maxY) - 6)))
}
threadPath = path.cgPath
let pieces = imageFrames + fileFrames + [bubbleFrame, emojiFrame, audioFrame, linkCardFrame].compactMap { $0 }
let replyTop = pieces.min { $0.minY < $1.minY } ?? primary
// The thread line drops from under the quote and joins the reply.
var quoteBody = quoteFrame
quoteBody.size.width -= t.tailWidth
if !quote.isOutgoing { quoteBody.origin.x += t.tailWidth }
threadPath = ConversationReplyThread.row(
quoteBody: quoteBody,
quoteSide: quote.isOutgoing ? .trailing : .leading,
quoteTailDrop: Self.quoteTailDrop(quoteHeight: quoteFrame.height, quoteBodyWidth: quoteBody.width),
// The reply's first piece of content: the line meets it, never crosses it.
reply: replyTop,
replySide: model.isOutgoing ? .trailing : .leading,
isGroup: model.isGroup,
avatarColumnCenter: margin + t.avatarSize / 2,
replyAvatarTop: !model.isOutgoing && model.reservesAvatarColumn ? (avatarFrame?.minY ?? primary.maxY) : nil
).path
}

var content = imageFrames.reduce(bubbleFrame ?? emojiFrame ?? .null) { $0.union($1) }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import CoreGraphics
import Testing
@testable import CmuxConversationGeometry

/// The inline reply's thread line, as Messages draws it (iOS 14 to 26
/// screenshots): it drops from under the quoted bubble (its tail, or a
/// group's avatar column) and turns toward a reply on the other side. It
/// never starts beside the quote at its middle, which drew a hook out of
/// the quote's side and through its tail (Chief, 2026-10-21).
@Suite struct ConversationReplyThreadTests {
typealias Thread = ConversationReplyThread
let tailDrop: CGFloat = 6.8

/// A 440 pt phone, 20 pt margins, 32 pt avatars.
func row(
quote: CGRect, quoteSide: ConversationBubbleGeometry.Side,
reply: CGRect, replySide: ConversationBubbleGeometry.Side,
isGroup: Bool = false, replyAvatarTop: CGFloat? = nil
) -> Thread {
Thread.row(quoteBody: quote, quoteSide: quoteSide, quoteTailDrop: tailDrop,
reply: reply, replySide: replySide, isGroup: isGroup,
avatarColumnCenter: 36, replyAvatarTop: replyAvatarTop)
}

@Test func aOneToOneReplyThreadsFromUnderTheQuotesTailTowardTheReply() {
// "u sure?" (outgoing) replying to Chief's message (incoming).
let quote = CGRect(x: 20, y: 0, width: 280, height: 34)
let reply = CGRect(x: 330, y: 47, width: 90, height: 36)
let thread = row(quote: quote, quoteSide: .leading, reply: reply, replySide: .trailing)
let box = thread.path.boundingBoxOfPath
#expect(box.minY >= quote.maxY + tailDrop, "the line starts under the quote and its tail")
#expect(box.minX >= quote.minX, "nothing sticks out of the quote's leading side")
#expect(box.maxX < reply.minX, "the turn points at the reply without reaching it")
#expect(thread.path.currentPoint.y == reply.midY)
#expect(thread.path.currentPoint.x > thread.columnX)
}

@Test func aReplyToAnOutgoingQuoteThreadsDownTheTrailingSide() {
// Chief (incoming) replying to my message (outgoing quote).
let quote = CGRect(x: 240, y: 0, width: 180, height: 34)
let reply = CGRect(x: 20, y: 47, width: 200, height: 36)
let thread = row(quote: quote, quoteSide: .trailing, reply: reply, replySide: .leading)
let box = thread.path.boundingBoxOfPath
#expect(box.minY >= quote.maxY + tailDrop)
#expect(box.minX >= quote.minX)
#expect(box.maxX <= quote.maxX)
#expect(thread.path.currentPoint.x < thread.columnX, "turns toward the reply on the leading side")
#expect(thread.path.currentPoint.y == reply.midY)
}

@Test func aSameSideReplyGetsAStraightLineThatStopsAboveIt() {
let quote = CGRect(x: 20, y: 0, width: 200, height: 34)
// The row's own spacing: the tail, the 6 pt small space, and the straight run's room.
let reply = CGRect(x: 20, y: 34 + tailDrop + 6 + Thread.minimumStraightRun, width: 160, height: 36)
let thread = row(quote: quote, quoteSide: .leading, reply: reply, replySide: .leading)
let box = thread.path.boundingBoxOfPath
#expect(box.width < 0.01)
#expect(box.minY >= quote.maxY + tailDrop)
#expect(box.maxY <= reply.minY)
#expect(box.height >= 10, "a visible line, not a dot")
}

@Test func aGroupReplyThreadsDownTheAvatarColumnToTheRepliersAvatar() {
// Incoming group rows: avatar column centered at 36, bubbles at 59.
let quote = CGRect(x: 59, y: 0, width: 200, height: 34)
let reply = CGRect(x: 59, y: 60, width: 160, height: 36)
let thread = row(quote: quote, quoteSide: .leading, reply: reply, replySide: .leading,
isGroup: true, replyAvatarTop: 64)
let box = thread.path.boundingBoxOfPath
#expect(box.width < 0.01)
#expect(box.minX == 36)
#expect(box.minY >= quote.maxY)
#expect(box.maxY == 58)
}

@Test func aGroupOutgoingReplyTurnsOutOfTheAvatarColumn() {
let quote = CGRect(x: 59, y: 0, width: 200, height: 34)
let reply = CGRect(x: 330, y: 47, width: 90, height: 36)
let thread = row(quote: quote, quoteSide: .leading, reply: reply, replySide: .trailing, isGroup: true)
let box = thread.path.boundingBoxOfPath
#expect(box.minX == 36)
#expect(box.minY >= quote.maxY)
#expect(thread.path.currentPoint == CGPoint(x: 56, y: reply.midY))
}

@Test func aReplyRightUnderTheQuoteStillDrawsDownward() {
let quote = CGRect(x: 20, y: 0, width: 200, height: 34)
let reply = CGRect(x: 330, y: 36, width: 90, height: 10)
let thread = row(quote: quote, quoteSide: .leading, reply: reply, replySide: .trailing)
#expect(thread.endY > thread.startY)
#expect(thread.path.boundingBoxOfPath.minY >= quote.maxY)
}
}