diff --git a/Packages/iOS/CmuxConversation/Sources/CmuxConversationGeometry/ConversationReplyThread.swift b/Packages/iOS/CmuxConversation/Sources/CmuxConversationGeometry/ConversationReplyThread.swift new file mode 100644 index 000000000000..76bf750dc943 --- /dev/null +++ b/Packages/iOS/CmuxConversation/Sources/CmuxConversationGeometry/ConversationReplyThread.swift @@ -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 + } +} diff --git a/Packages/iOS/CmuxConversation/Sources/CmuxConversationUI/MessageCellLayout.swift b/Packages/iOS/CmuxConversation/Sources/CmuxConversationUI/MessageCellLayout.swift index 7650e9fc69c6..5ac38a252942 100644 --- a/Packages/iOS/CmuxConversation/Sources/CmuxConversationUI/MessageCellLayout.swift +++ b/Packages/iOS/CmuxConversation/Sources/CmuxConversationUI/MessageCellLayout.swift @@ -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), @@ -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? @@ -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) } diff --git a/Packages/iOS/CmuxConversation/Tests/CmuxConversationGeometryTests/ConversationReplyThreadTests.swift b/Packages/iOS/CmuxConversation/Tests/CmuxConversationGeometryTests/ConversationReplyThreadTests.swift new file mode 100644 index 000000000000..01ab9f6de9ea --- /dev/null +++ b/Packages/iOS/CmuxConversation/Tests/CmuxConversationGeometryTests/ConversationReplyThreadTests.swift @@ -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) + } +}