Files
mobile-ios/Rosetta/Core/Layout/MessageCellLayout.swift

1027 lines
45 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import UIKit
import CoreText
/// Pre-calculated layout for a message cell.
/// Computed on ANY thread (background-safe). Applied on main thread.
/// This is the Rosetta equivalent of Telegram's `asyncLayout()` pattern.
///
/// Pattern:
/// 1. `MessageCellLayout.calculate(message:, config:)` runs on background
/// 2. Returns `MessageCellLayout` with ALL frame rects
/// 3. `NativeMessageCell.apply(layout:)` runs on main thread, just sets frames
struct MessageCellLayout: Sendable {
// MARK: - Cell
var totalHeight: CGFloat
let groupGap: CGFloat
let isOutgoing: Bool
let position: BubblePosition
let messageType: MessageType
// MARK: - Bubble
let bubbleFrame: CGRect // Bubble view frame in cell coords
let bubbleSize: CGSize // Bubble size (for shape path)
let mergeType: BubbleMergeType
let hasTail: Bool
// MARK: - Text
let textFrame: CGRect // Text label frame in bubble coords
let textSize: CGSize
let timestampInline: Bool // true = timestamp on same line as last text line
// MARK: - Timestamp
let timestampFrame: CGRect // Timestamp label frame in bubble coords
let checkSentFrame: CGRect // Sent-check () frame in bubble coords
let checkReadFrame: CGRect // Read-check (/) frame in bubble coords (overlaps sent for )
let clockFrame: CGRect // Sending clock frame in bubble coords
let showsDeliveryFailedIndicator: Bool
let deliveryFailedInset: CGFloat
// MARK: - Reply Quote (optional)
let hasReplyQuote: Bool
let replyContainerFrame: CGRect
let replyBarFrame: CGRect
let replyNameFrame: CGRect
let replyTextFrame: CGRect
// MARK: - Photo (optional)
let hasPhoto: Bool
let photoFrame: CGRect // Photo view frame in bubble coords
let photoCollageHeight: CGFloat
// MARK: - File (optional)
let hasFile: Bool
let fileFrame: CGRect // File view frame in bubble coords
// MARK: - Forward Header (optional)
let isForward: Bool
let forwardHeaderFrame: CGRect
let forwardAvatarFrame: CGRect
let forwardNameFrame: CGRect
// MARK: - Date Header (optional)
let showsDateHeader: Bool
let dateHeaderText: String
let dateHeaderHeight: CGFloat
// MARK: - Group Sender (optional)
var showsSenderName: Bool // true for .top/.single incoming in group
var showsSenderAvatar: Bool // true for .bottom/.single incoming in group
var senderName: String
var senderKey: String
// MARK: - Types
enum MessageType: Sendable {
case text
case textWithReply
case photo
case photoWithCaption
case file
case forward
}
}
// MARK: - Layout Calculation (Thread-Safe)
extension MessageCellLayout {
struct Config: Sendable {
let maxBubbleWidth: CGFloat
let isOutgoing: Bool
let position: BubblePosition
let deliveryStatus: DeliveryStatus
let text: String
let timestampText: String
let hasReplyQuote: Bool
let replyName: String?
let replyText: String?
let imageCount: Int
let imageDimensions: CGSize?
let fileCount: Int
let avatarCount: Int
let callCount: Int
let isForward: Bool
let forwardImageCount: Int
let forwardFileCount: Int
let forwardCaption: String?
let showsDateHeader: Bool
let dateHeaderText: String
}
private struct MediaDimensions {
let maxWidth: CGFloat
let maxHeight: CGFloat
let minWidth: CGFloat
let minHeight: CGFloat
}
private struct TextStatusLaneMetrics {
let textToMetadataGap: CGFloat
let timeToCheckGap: CGFloat
let textStatusRightInset: CGFloat
let statusWidth: CGFloat
let checkOffset: CGFloat
let verticalOffset: CGFloat
let checkBaselineOffset: CGFloat
static func telegram(fontPointSize: CGFloat, screenPixel: CGFloat) -> TextStatusLaneMetrics {
TextStatusLaneMetrics(
textToMetadataGap: 5,
timeToCheckGap: 5,
textStatusRightInset: 5,
statusWidth: floor(floor(fontPointSize * 13.0 / 17.0)),
checkOffset: floor(fontPointSize * 6.0 / 17.0),
// Lift text status lane by one pixel to match Telegram vertical seating.
verticalOffset: -screenPixel,
checkBaselineOffset: 3.0 - screenPixel
)
}
}
/// Calculate complete cell layout on ANY thread.
/// Uses CoreText for text measurement (thread-safe).
/// Returns layout with all frame rects + cached CoreTextTextLayout for rendering.
///
/// Telegram-style tight bubbles: timestamp goes inline with last text line
/// when there's space, or on a new line when there isn't.
static func calculate(config: Config) -> (layout: MessageCellLayout, textLayout: CoreTextTextLayout?) {
let font = UIFont.systemFont(ofSize: 17, weight: .regular)
let tsFont = UIFont.systemFont(ofSize: floor(font.pointSize * 11.0 / 17.0), weight: .regular)
let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
let metrics = BubbleMetrics.telegram()
let mergeType = BubbleGeometryEngine.mergeType(for: config.position)
let hasTail = BubbleGeometryEngine.hasTail(for: mergeType)
let isOutgoingFailed = config.isOutgoing && config.deliveryStatus == .error
let deliveryFailedInset: CGFloat = isOutgoingFailed ? 24 : 0
let effectiveMaxBubbleWidth = max(40, config.maxBubbleWidth - deliveryFailedInset)
// Classify message type
let messageType: MessageType
if config.isForward {
messageType = .forward
} else if config.imageCount > 0 && !config.text.isEmpty {
messageType = .photoWithCaption
} else if config.imageCount > 0 {
messageType = .photo
} else if config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0 {
messageType = .file
} else if config.hasReplyQuote {
messageType = .textWithReply
} else {
messageType = .text
}
let isTextMessage = (messageType == .text || messageType == .textWithReply)
let isForwardWithCaption = messageType == .forward && !config.text.isEmpty
let textStatusLaneMetrics = TextStatusLaneMetrics.telegram(
fontPointSize: font.pointSize,
screenPixel: screenPixel
)
let groupGap: CGFloat = {
guard config.position == .mid || config.position == .bottom else {
return metrics.defaultSpacing
}
// Keep grouped text bubbles compact, but still visually split.
if isTextMessage {
return screenPixel
}
return metrics.mergedSpacing
}()
// STEP 1: Asymmetric paddings + base text measurement (full width)
let topPad: CGFloat = metrics.textInsets.top
let bottomPad: CGFloat = metrics.textInsets.bottom
let leftPad: CGFloat = metrics.textInsets.left
let rightPad: CGFloat = metrics.textInsets.right
// Outgoing: timestamp at 5pt from edge (checkmarks fill remaining space rightPad-5=6pt compensation)
// Incoming: timestamp at rightPad (11pt) from edge, same as text 0pt compensation
let statusTrailingCompensation: CGFloat
if (isTextMessage || isForwardWithCaption) && config.isOutgoing {
statusTrailingCompensation = max(0, rightPad - textStatusLaneMetrics.textStatusRightInset)
} else {
statusTrailingCompensation = 0
}
// maxTextWidth = effectiveMaxBubbleWidth - (leftPad + rightPad)
// Text is measured at the WIDEST possible constraint.
let maxTextWidth = effectiveMaxBubbleWidth - leftPad - rightPad
let textMeasurement: TextMeasurement
var cachedTextLayout: CoreTextTextLayout?
let needsDetailedTextLayout = isTextMessage || messageType == .photoWithCaption || (messageType == .forward && !config.text.isEmpty)
if !config.text.isEmpty && needsDetailedTextLayout {
// CoreText (CTTypesetter) returns per-line widths including lastLineWidth.
// Also captures CoreTextTextLayout for cell rendering (avoids double computation).
let (measurement, layout) = measureTextDetailedWithLayout(config.text, maxWidth: max(maxTextWidth, 50), font: font)
textMeasurement = measurement
cachedTextLayout = layout
} else if !config.text.isEmpty {
// Forwards, files (no CoreTextLabel rendering needed)
let size = measureText(config.text, maxWidth: max(maxTextWidth, 50), font: font)
textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width)
} else {
textMeasurement = TextMeasurement(size: .zero, trailingLineWidth: 0)
}
// STEP 2: Meta-info dimensions
let timestampText = config.timestampText.isEmpty ? "00:00" : config.timestampText
let tsSize = measureText(timestampText, maxWidth: 60, font: tsFont)
let hasStatusIcon = config.isOutgoing && !isOutgoingFailed
let statusWidth: CGFloat = hasStatusIcon
? textStatusLaneMetrics.statusWidth
: 0
let checkW: CGFloat = statusWidth
let timeToCheckGap: CGFloat = hasStatusIcon
? textStatusLaneMetrics.timeToCheckGap
: 0
let metadataWidth = tsSize.width + timeToCheckGap + checkW
let trailingWidthForStatus: CGFloat
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
if let cachedTextLayout {
if cachedTextLayout.lastLineHasRTL {
trailingWidthForStatus = 10_000
} else if cachedTextLayout.lastLineHasBlockQuote {
trailingWidthForStatus = textMeasurement.size.width
} else {
trailingWidthForStatus = textMeasurement.trailingLineWidth
}
} else {
trailingWidthForStatus = textMeasurement.trailingLineWidth
}
} else {
trailingWidthForStatus = 0
}
let inlineStatusContentWidth = max(
0,
trailingWidthForStatus
+ textStatusLaneMetrics.textToMetadataGap
+ metadataWidth
- statusTrailingCompensation
)
let wrappedStatusContentWidth = max(
0,
metadataWidth - statusTrailingCompensation
)
let isShortSingleLineText: Bool = {
guard messageType == .text, !config.text.isEmpty else { return false }
guard !config.text.contains("\n") else { return false }
if let cachedTextLayout {
return cachedTextLayout.lines.count == 1
}
return textMeasurement.size.height <= ceil(font.lineHeight + 1.0)
}()
// STEP 3: Inline vs Wrapped determination
let timestampInline: Bool
if (isTextMessage || isForwardWithCaption) && !config.text.isEmpty {
timestampInline = inlineStatusContentWidth <= maxTextWidth
} else {
timestampInline = true
}
// STEP 4: Bubble dimensions (unified width + height)
// Content blocks above the text area
let mediaDimensions = Self.mediaDimensions(for: UIScreen.main.bounds.width)
// Telegram reply: 3pt top pad + 17pt name + 2pt spacing + 17pt text + 3pt bottom = 42pt container
let replyContainerH: CGFloat = 42
let replyTopInset: CGFloat = 5
let replyBottomGap: CGFloat = 3
let replyH: CGFloat = config.hasReplyQuote ? (replyTopInset + replyContainerH + replyBottomGap - topPad) : 0
var photoH: CGFloat = 0
let forwardHeaderH: CGFloat = config.isForward ? 41 : 0
var fileH: CGFloat = CGFloat(config.fileCount) * 52
+ CGFloat(config.callCount) * 42
+ CGFloat(config.avatarCount) * 52
// Tiny floor just to prevent zero-width collapse.
// Telegram does NOT force a large minW short messages get tight bubbles.
let minW: CGFloat = 40
let mediaBubbleMaxWidth = min(effectiveMaxBubbleWidth, mediaDimensions.maxWidth)
let mediaBubbleMinWidth = min(mediaDimensions.minWidth, mediaBubbleMaxWidth)
var bubbleW: CGFloat
var bubbleH: CGFloat = replyH + forwardHeaderH + fileH
var fileOnlyTsPad: CGFloat = 0 // symmetric bottom inset for file-only bubbles
if config.imageCount > 0 {
// Telegram-exact photo sizing (ChatMessageInteractiveMediaNode.swift):
// 1. unboundSize = pixelDims * 0.5 (or 200×100 default)
// 2. fitted = unbound.aspectFitted(maxDimensions)
// 3. width = min(unbound.width, fitted.width, maxWidth) KEY: cap to natural size
// 4. height = fit to width preserving aspect ratio, then clamp
// 5. Photo inset: 2pt on ALL four sides
let photoInset: CGFloat = 2
if config.imageCount == 1, let dims = config.imageDimensions {
// Telegram-exact: unboundSize = pixelDims * 0.5, then aspectFitted
let unbound = CGSize(
width: floor(dims.width * 0.5),
height: floor(dims.height * 0.5)
)
let maxConstraint = CGSize(width: mediaBubbleMaxWidth, height: mediaDimensions.maxHeight)
let fitted = unbound.aspectFitted(maxConstraint)
// Telegram: resultWidth = min(nativeSize.width, fitted.width, maxWidth)
// This prevents scaling UP bubble can't exceed natural photo size
bubbleW = max(mediaBubbleMinWidth,
min(ceil(unbound.width), min(ceil(fitted.width), mediaBubbleMaxWidth)))
// Fit height to the chosen width, preserving aspect ratio
let aspectH = ceil(bubbleW * unbound.height / unbound.width)
photoH = max(mediaDimensions.minHeight, min(aspectH, mediaDimensions.maxHeight))
} else if config.imageCount == 1 {
// No dimensions (legacy messages) use full max width with 0.75 aspect
bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
photoH = max(mediaDimensions.minHeight,
min(ceil(bubbleW * 0.75), mediaDimensions.maxHeight))
} else {
bubbleW = max(mediaBubbleMinWidth, mediaBubbleMaxWidth)
photoH = Self.collageHeight(
count: config.imageCount,
width: bubbleW - 4,
maxHeight: mediaDimensions.maxHeight,
minHeight: mediaDimensions.minHeight
)
}
// Photo+caption: ensure bubble is wide enough for text
if !config.text.isEmpty {
let textNeedW = leftPad + textMeasurement.size.width + rightPad
bubbleW = max(bubbleW, min(textNeedW, effectiveMaxBubbleWidth))
}
// Telegram: 2pt inset on all 4 sides bubble is photoH + 4pt taller
bubbleH += photoH + photoInset * 2
if !config.text.isEmpty {
bubbleH += topPad + textMeasurement.size.height + bottomPad
if photoH > 0 { bubbleH += 6 }
}
} else if isTextMessage && !config.text.isEmpty {
// EXACT TELEGRAM MATH no other modifiers
let actualTextW = textMeasurement.size.width
let finalContentW: CGFloat
if timestampInline {
// INLINE: width = max(widest line, last line + gap + status)
finalContentW = max(
actualTextW,
inlineStatusContentWidth
)
bubbleH += topPad + textMeasurement.size.height + bottomPad
} else {
// WRAPPED: status drops to new line below text
finalContentW = max(actualTextW, wrappedStatusContentWidth)
bubbleH += topPad + textMeasurement.size.height + 15 + bottomPad
}
// Set bubble width TIGHTLY: leftPad + content + rightPad
bubbleW = leftPad + finalContentW + rightPad
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
if config.hasReplyQuote { bubbleW = max(bubbleW, 180) }
} else if isForwardWithCaption {
// Forward with caption SAME sizing as text messages (Telegram parity)
// 2pt gap from forward header to text (Telegram: spacing after forward = 2pt)
let actualTextW = textMeasurement.size.width
let finalContentW: CGFloat
if timestampInline {
finalContentW = max(actualTextW, inlineStatusContentWidth)
bubbleH += 2 + textMeasurement.size.height + bottomPad
} else {
finalContentW = max(actualTextW, wrappedStatusContentWidth)
bubbleH += 2 + textMeasurement.size.height + 15 + bottomPad
}
bubbleW = leftPad + finalContentW + rightPad
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
} else if !config.text.isEmpty {
// Non-text with caption (file)
let finalContentW = max(textMeasurement.size.width, metadataWidth)
bubbleW = leftPad + finalContentW + rightPad
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220
if fileH > 0 { bubbleW = max(bubbleW, min(fileMinW, effectiveMaxBubbleWidth)) }
bubbleH += topPad + textMeasurement.size.height + bottomPad
} else if fileH > 0 {
// Telegram: call width = title + button(54) + insets 200pt
// Telegram: file width = icon(55) + filename + insets 220pt
let fileMinW: CGFloat = config.callCount > 0 ? 200 : 220
bubbleW = min(fileMinW, effectiveMaxBubbleWidth)
bubbleW = max(bubbleW, leftPad + metadataWidth + rightPad)
// Symmetric centering: content + gap + timestamp block centered in bubble.
// Content is centered within fileContainer (in layoutSubviews) above the timestamp.
// To achieve visual symmetry, fileH spans the ENTIRE bubble
// and metadataBottomInset = (fileH - contentH) / 2 (same as content topY).
let tsGap: CGFloat = 6
let contentH: CGFloat = config.callCount > 0 ? 36 : 44
let tsPad = ceil((fileH + tsGap - contentH) / 2)
fileOnlyTsPad = tsPad
bubbleH += tsGap + tsSize.height + tsPad
fileH = bubbleH // fileContainer spans entire bubble
} else {
// No text, no file (forward header only, empty)
bubbleW = leftPad + metadataWidth + rightPad
bubbleW = max(minW, min(bubbleW, effectiveMaxBubbleWidth))
}
if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward {
bubbleH = max(bubbleH, 37)
}
// Forward header needs minimum width for "Forwarded from" + avatar + name
if config.isForward {
bubbleW = max(bubbleW, min(200, effectiveMaxBubbleWidth))
}
// Stretchable bubble image min height
bubbleH = max(bubbleH, 37)
// Forward/reply text area must match regular text bubble min-height behavior.
// Without this, text and timestamp share the same bottom edge (look "centered").
// With this, the text area gets the same ~5pt extra padding as regular text bubbles.
if (isForwardWithCaption || (config.hasReplyQuote && !config.text.isEmpty)) {
let headerH = forwardHeaderH + replyH
let textAreaH = bubbleH - headerH
if textAreaH < 37 {
bubbleH = headerH + 37
}
}
// Date header adds height above the bubble.
let dateHeaderH: CGFloat = config.showsDateHeader ? 42 : 0
let totalH = dateHeaderH + groupGap + bubbleH
// Bubble X (approximate overridden in layoutSubviews with actual cellWidth)
let bubbleX: CGFloat = config.isOutgoing ? effectiveMaxBubbleWidth - bubbleW : 8
let bubbleFrame = CGRect(x: bubbleX, y: dateHeaderH + groupGap, width: bubbleW, height: bubbleH)
// STEP 5: Geometry assignment
// Metadata frames:
// checkFrame.maxX = bubbleW - textStatusRightInset for text bubbles
// tsFrame.maxX = checkFrame.minX - timeToCheckGap
// checkFrame.minX = bubbleW - inset - checkW
let metadataRightInset: CGFloat
if messageType == .photo {
// Telegram: statusInsets = (top:0, left:0, bottom:6, right:6) from PHOTO edges.
// Pill right = statusEndX + mediaStatusInsets.right(7) = bubbleW - X + 7
// Photo right = bubbleW - 2. Gap = 6pt pill right = bubbleW - 8.
// statusEndX = bubbleW - 15 metadataRightInset = 15.
metadataRightInset = 15
} else if isTextMessage || isForwardWithCaption || messageType == .photoWithCaption {
metadataRightInset = config.isOutgoing
? textStatusLaneMetrics.textStatusRightInset
: rightPad
} else {
metadataRightInset = rightPad
}
// Telegram: pill bottom = statusEndY + mediaStatusInsets.bottom(2).
// Photo bottom = bubbleH - 2. Gap = 6pt pill bottom = bubbleH - 8.
// statusEndY = bubbleH - 10 metadataBottomInset = 10.
let metadataBottomInset: CGFloat
if messageType == .photo {
metadataBottomInset = 10
} else if messageType == .file && config.text.isEmpty {
metadataBottomInset = fileOnlyTsPad
} else {
metadataBottomInset = bottomPad
}
let statusEndX = bubbleW - metadataRightInset
let statusEndY = bubbleH - metadataBottomInset
let statusVerticalOffset: CGFloat = (isTextMessage || isForwardWithCaption)
? textStatusLaneMetrics.verticalOffset
: 0
let tsFrame: CGRect
if config.isOutgoing {
// [timestamp][timeGap][checkW] anchored right at statusEndX
tsFrame = CGRect(
x: statusEndX - checkW - timeToCheckGap - tsSize.width,
y: statusEndY - tsSize.height + statusVerticalOffset,
width: tsSize.width, height: tsSize.height
)
} else {
// Incoming: [timestamp] anchored right at statusEndX
tsFrame = CGRect(
x: statusEndX - tsSize.width,
y: statusEndY - tsSize.height + statusVerticalOffset,
width: tsSize.width, height: tsSize.height
)
}
let checkSentFrame: CGRect
let checkReadFrame: CGRect
let clockFrame: CGRect
if hasStatusIcon {
let checkImgW: CGFloat = floor(floor(font.pointSize * 11.0 / 17.0))
let checkImgH: CGFloat = floor(checkImgW * 9.0 / 11.0)
let checkOffset: CGFloat = (isTextMessage || isForwardWithCaption)
? textStatusLaneMetrics.checkOffset
: floor(font.pointSize * 6.0 / 17.0)
let checkReadX = statusEndX - checkImgW
let checkSentX = checkReadX - checkOffset
let checkBaselineOffset: CGFloat = isTextMessage
? textStatusLaneMetrics.checkBaselineOffset
: (3 - screenPixel)
let checkY = tsFrame.minY + checkBaselineOffset
checkSentFrame = CGRect(x: checkSentX, y: checkY, width: checkImgW, height: checkImgH)
checkReadFrame = CGRect(x: checkReadX, y: checkY, width: checkImgW, height: checkImgH)
// Telegram DateAndStatusNode:
// clock origin X = dateFrame.maxX + 3.0, center Y aligned with checks.
clockFrame = CGRect(x: tsFrame.maxX + 3.0, y: checkY - 1.0, width: 11, height: 11)
} else {
checkSentFrame = .zero
checkReadFrame = .zero
clockFrame = .zero
}
// Text frame MUST fill bubbleW - leftPad - rightPad (the content area),
// NOT textMeasurement.size.width. Using the measured width causes UILabel to
// re-wrap at a narrower constraint than CoreText measured, producing different
// line breaks ("jagged first line"). The content area is always measured width.
var textY: CGFloat = topPad
if config.hasReplyQuote { textY = replyH + topPad }
if forwardHeaderH > 0 { textY = forwardHeaderH + 2 }
if photoH > 0 {
// Photo has 2pt inset top + 2pt inset bottom, so text starts after photoH + 4 + gap
textY = photoH + 4 + 6 + topPad
if config.hasReplyQuote { textY = replyH + photoH + 4 + 6 + topPad }
}
if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) + topPad }
if isShortSingleLineText && timestampInline {
// Optical centering for short one-line text without inflating bubble height.
let maxTextY = tsFrame.minY - textStatusLaneMetrics.textToMetadataGap - textMeasurement.size.height
if maxTextY > textY {
textY = min(textY + 1.5, maxTextY)
}
}
let textFrame = CGRect(
x: leftPad,
y: textY,
width: bubbleW - leftPad - rightPad,
height: textMeasurement.size.height
)
// Accessory frames (reply, photo, file, forward)
let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: replyContainerH)
let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: replyContainerH)
let replyNameFrame = CGRect(x: 11, y: 3, width: bubbleW - 27, height: 17)
let replyTextFrame = CGRect(x: 11, y: 22, width: bubbleW - 27, height: 17)
// Telegram: 2pt inset on all four sides between photo and bubble edge
let photoY: CGFloat = (config.hasReplyQuote ? replyH : 0) + 2
let photoFrame = CGRect(x: 2, y: photoY, width: bubbleW - 4, height: photoH)
let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH)
let fwdHeaderFrame = CGRect(x: 10, y: 7, width: bubbleW - 20, height: 17)
let fwdAvatarFrame = CGRect(x: 10, y: 24, width: 16, height: 16)
let fwdNameFrame = CGRect(x: 30, y: 24, width: bubbleW - 40, height: 17)
let layout = MessageCellLayout(
totalHeight: totalH,
groupGap: groupGap,
isOutgoing: config.isOutgoing,
position: config.position,
messageType: messageType,
bubbleFrame: bubbleFrame,
bubbleSize: CGSize(width: bubbleW, height: bubbleH),
mergeType: mergeType,
hasTail: hasTail,
textFrame: textFrame,
textSize: textMeasurement.size,
timestampInline: timestampInline,
timestampFrame: tsFrame,
checkSentFrame: checkSentFrame,
checkReadFrame: checkReadFrame,
clockFrame: clockFrame,
showsDeliveryFailedIndicator: isOutgoingFailed,
deliveryFailedInset: deliveryFailedInset,
hasReplyQuote: config.hasReplyQuote,
replyContainerFrame: replyContainerFrame,
replyBarFrame: replyBarFrame,
replyNameFrame: replyNameFrame,
replyTextFrame: replyTextFrame,
hasPhoto: config.imageCount > 0,
photoFrame: photoFrame,
photoCollageHeight: photoH,
hasFile: config.fileCount > 0 || config.avatarCount > 0 || config.callCount > 0,
fileFrame: fileFrame,
isForward: config.isForward,
forwardHeaderFrame: fwdHeaderFrame,
forwardAvatarFrame: fwdAvatarFrame,
forwardNameFrame: fwdNameFrame,
showsDateHeader: config.showsDateHeader,
dateHeaderText: config.dateHeaderText,
dateHeaderHeight: dateHeaderH,
showsSenderName: false,
showsSenderAvatar: false,
senderName: "",
senderKey: ""
)
return (layout, cachedTextLayout)
}
// MARK: - Collage Height (Thread-Safe)
/// Photo collage height same formulas as PhotoCollageView.swift.
private static func mediaDimensions(for screenWidth: CGFloat) -> MediaDimensions {
if screenWidth > 680 {
return MediaDimensions(maxWidth: 440, maxHeight: 440, minWidth: 170, minHeight: 74)
}
return MediaDimensions(maxWidth: 300, maxHeight: 380, minWidth: 170, minHeight: 74)
}
private static func collageHeight(
count: Int,
width: CGFloat,
maxHeight: CGFloat,
minHeight: CGFloat
) -> CGFloat {
guard count > 0 else { return 0 }
if count == 1 { return min(max(width * 0.93, minHeight), maxHeight) }
if count == 2 {
let cellW = (width - 2) / 2
return min(max(cellW * 1.28, minHeight), maxHeight)
}
if count == 3 {
let leftW = width * 0.66
return min(max(leftW * 1.16, minHeight), maxHeight)
}
if count == 4 {
let cellW = (width - 2) / 2
let cellH = min(max(cellW * 0.85, minHeight / 2), maxHeight / 2)
return min(max(cellH * 2 + 2, minHeight), maxHeight)
}
// 5+
let topH = min(width / 2 * 0.85, 176)
let botH = min(width / 3 * 0.85, 144)
return min(max(topH + 2 + botH, minHeight), maxHeight)
}
// MARK: - Text Measurement (Thread-Safe)
/// Simple text measurement safe to call from any thread.
private static func measureText(_ text: String, maxWidth: CGFloat, font: UIFont) -> CGSize {
guard !text.isEmpty else { return .zero }
let attrs: [NSAttributedString.Key: Any] = [.font: font]
let attrStr = NSAttributedString(string: text, attributes: attrs)
let rect = attrStr.boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: .usesLineFragmentOrigin,
context: nil
)
return CGSize(width: ceil(rect.width), height: ceil(rect.height))
}
/// Detailed text measurement result (Telegram pattern).
struct TextMeasurement: Sendable {
let size: CGSize // Bounding box (max line width, total height)
let trailingLineWidth: CGFloat // Width of the LAST line only
}
/// Telegram-exact text measurement using CTTypesetter + manual line breaking.
/// Returns BOTH measurement AND the full CoreTextTextLayout for cell rendering cache.
/// This eliminates the double CoreText computation (measure + render).
private static func measureTextDetailedWithLayout(
_ text: String, maxWidth: CGFloat, font: UIFont
) -> (TextMeasurement, CoreTextTextLayout) {
let layout = CoreTextTextLayout.calculate(
text: text, maxWidth: maxWidth, font: font, textColor: .white
)
let measurement = TextMeasurement(
size: layout.size,
trailingLineWidth: layout.lastLineWidth
)
return (measurement, layout)
}
// MARK: - Garbage Text Detection (Thread-Safe)
/// Returns true if text is garbage (U+FFFD, control chars) or encrypted ciphertext.
static func isGarbageOrEncrypted(_ text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
// Check for U+FFFD / control characters
let validChars = trimmed.unicodeScalars.filter { scalar in
scalar.value != 0xFFFD &&
scalar.value > 0x1F &&
scalar.value != 0x7F
}
if validChars.isEmpty { return true }
// Chunked format
if trimmed.hasPrefix("CHNK:") { return true }
// Check for encrypted ciphertext: Base64:Base64 pattern
let parts = trimmed.components(separatedBy: ":")
if parts.count == 2 && parts[0].count >= 16 && parts[1].count >= 16 {
let base64Chars = CharacterSet.alphanumerics
.union(CharacterSet(charactersIn: "+/="))
let allBase64 = parts.allSatisfy { part in
!part.isEmpty && part.unicodeScalars.allSatisfy { base64Chars.contains($0) }
}
if allBase64 { return true }
}
// Pure hex string (40 chars) XChaCha20 wire format
if trimmed.count >= 40 {
let hexChars = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
if trimmed.unicodeScalars.allSatisfy({ hexChars.contains($0) }) { return true }
}
return false
}
// MARK: - Bubble Grouping (Telegram-like)
private enum BubbleGroupingKind {
case text
case media
case file
case forward
}
/// Conservative grouping window to keep groups visually close to Telegram behavior.
/// Messages far apart in time should split into separate bubble groups.
private static let mergeTimeWindowMs: Int64 = 10 * 60 * 1000
private static func groupingKind(for message: ChatMessage, displayText: String) -> BubbleGroupingKind {
let hasImage = message.attachments.contains { $0.type == .image }
if hasImage {
return .media
}
let hasFileLike = message.attachments.contains { $0.type == .file || $0.type == .avatar || $0.type == .call }
if hasFileLike {
return .file
}
let hasReplyAttachment = message.attachments.contains { $0.type == .messages }
if hasReplyAttachment && displayText.isEmpty {
return .forward
}
return .text
}
private static func isFromMe(_ message: ChatMessage, currentPublicKey: String) -> Bool {
message.fromPublicKey == currentPublicKey
}
private static func timestampDeltaMs(_ lhs: Int64, _ rhs: Int64) -> Int64 {
lhs >= rhs ? (lhs - rhs) : (rhs - lhs)
}
private static func shouldMerge(
current message: ChatMessage,
currentDisplayText: String,
with neighbor: ChatMessage,
neighborDisplayText: String,
currentPublicKey: String
) -> Bool {
// Telegram-like: only same direction (incoming with incoming / outgoing with outgoing)
guard isFromMe(message, currentPublicKey: currentPublicKey) == isFromMe(neighbor, currentPublicKey: currentPublicKey) else {
return false
}
// Group chats: different senders NEVER merge (Telegram parity).
if message.fromPublicKey != neighbor.fromPublicKey {
return false
}
// Keep failed messages visually isolated (external failed indicator behavior).
if message.deliveryStatus == .error || neighbor.deliveryStatus == .error {
return false
}
// Break groups at day boundaries (date separator will appear between them).
let msgDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
let neighborDate = Date(timeIntervalSince1970: TimeInterval(neighbor.timestamp) / 1000)
if !Calendar.current.isDate(msgDate, inSameDayAs: neighborDate) {
return false
}
// Long gaps should split groups.
if timestampDeltaMs(message.timestamp, neighbor.timestamp) >= mergeTimeWindowMs {
return false
}
let currentKind = groupingKind(for: message, displayText: currentDisplayText)
let neighborKind = groupingKind(for: neighbor, displayText: neighborDisplayText)
// Forwards never merge (Telegram parity). All other kinds merge freely.
if currentKind == .forward || neighborKind == .forward {
return false
}
return true
}
}
// MARK: - Batch Calculation (Background Thread)
extension MessageCellLayout {
/// Pre-calculate layouts for all messages on background queue.
/// Returns both frame layouts AND cached CoreTextTextLayouts for cell rendering.
/// Telegram equivalent: ListView calls asyncLayout() on background.
static func batchCalculate(
messages: [ChatMessage],
maxBubbleWidth: CGFloat,
currentPublicKey: String,
opponentPublicKey: String,
opponentTitle: String,
isGroupChat: Bool = false
) -> (layouts: [String: MessageCellLayout], textLayouts: [String: CoreTextTextLayout]) {
var result: [String: MessageCellLayout] = [:]
var textResult: [String: CoreTextTextLayout] = [:]
let timestampFormatter = DateFormatter()
timestampFormatter.dateFormat = "HH:mm"
timestampFormatter.locale = .autoupdatingCurrent
timestampFormatter.timeZone = .autoupdatingCurrent
let calendar = Calendar.current
let now = Date()
let sameYearFormatter = DateFormatter()
sameYearFormatter.dateFormat = "MMMM d"
sameYearFormatter.locale = .autoupdatingCurrent
let diffYearFormatter = DateFormatter()
diffYearFormatter.dateFormat = "MMMM d, yyyy"
diffYearFormatter.locale = .autoupdatingCurrent
for (index, message) in messages.enumerated() {
let isOutgoing = message.fromPublicKey == currentPublicKey
let messageDate = Date(timeIntervalSince1970: TimeInterval(message.timestamp) / 1000)
let timestampText = timestampFormatter.string(from: messageDate)
// Date header: show on first message of each calendar day
let showsDateHeader: Bool
if index == 0 {
showsDateHeader = true
} else {
let prevDate = Date(timeIntervalSince1970: TimeInterval(messages[index - 1].timestamp) / 1000)
showsDateHeader = !calendar.isDate(messageDate, inSameDayAs: prevDate)
}
let dateHeaderText = showsDateHeader
? Self.formatDateHeader(messageDate, now: now, calendar: calendar,
sameYearFormatter: sameYearFormatter,
diffYearFormatter: diffYearFormatter)
: ""
// Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView)
// Convert emoji shortcodes (:emoji_1f631: 😱) Android/Desktop send shortcodes.
let rawText = isGarbageOrEncrypted(message.text) ? "" : message.text
let displayText = EmojiParser.replaceShortcodes(in: rawText)
// Calculate position (Telegram-like grouping rules)
let position: BubblePosition = {
let hasPrev: Bool = {
guard index > 0 else { return false }
let prev = messages[index - 1]
let prevDisplayText = isGarbageOrEncrypted(prev.text) ? "" : prev.text
return shouldMerge(
current: message,
currentDisplayText: displayText,
with: prev,
neighborDisplayText: prevDisplayText,
currentPublicKey: currentPublicKey
)
}()
let hasNext: Bool = {
guard index + 1 < messages.count else { return false }
let next = messages[index + 1]
let nextDisplayText = isGarbageOrEncrypted(next.text) ? "" : next.text
return shouldMerge(
current: message,
currentDisplayText: displayText,
with: next,
neighborDisplayText: nextDisplayText,
currentPublicKey: currentPublicKey
)
}()
switch (hasPrev, hasNext) {
case (false, false): return .single
case (false, true): return .top
case (true, true): return .mid
case (true, false): return .bottom
}
}()
// Classify
let images = message.attachments.filter { $0.type == .image }
let files = message.attachments.filter { $0.type == .file }
let avatars = message.attachments.filter { $0.type == .avatar }
let calls = message.attachments.filter { $0.type == .call }
let hasReply = message.attachments.contains { $0.type == .messages }
let isForward = hasReply && displayText.isEmpty
// Parse forward content from .messages blob
var forwardCaption: String?
var forwardInnerImageCount = 0
var forwardInnerFileCount = 0
if isForward,
let att = message.attachments.first(where: { $0.type == .messages }),
let data = att.blob.data(using: .utf8),
let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data),
let first = replies.first {
let fwdText = first.message.trimmingCharacters(in: .whitespacesAndNewlines)
if !fwdText.isEmpty && !isGarbageOrEncrypted(fwdText) {
forwardCaption = EmojiParser.replaceShortcodes(in: fwdText)
}
forwardInnerImageCount = first.attachments.filter { $0.type == 0 }.count
forwardInnerFileCount = first.attachments.filter { $0.type == 2 }.count
}
// Parse image dimensions from preview field (format: "tag::blurhash::WxH")
let imageDims: CGSize? = images.first.flatMap {
AttachmentPreviewCodec.imageDimensions(from: $0.preview)
}
let config = Config(
maxBubbleWidth: maxBubbleWidth,
isOutgoing: isOutgoing,
position: position,
deliveryStatus: message.deliveryStatus,
text: isForward ? (forwardCaption ?? "") : displayText,
timestampText: timestampText,
hasReplyQuote: hasReply && !displayText.isEmpty,
replyName: nil,
replyText: nil,
imageCount: images.count,
imageDimensions: imageDims,
fileCount: files.count,
avatarCount: avatars.count,
callCount: calls.count,
isForward: isForward,
forwardImageCount: forwardInnerImageCount,
forwardFileCount: forwardInnerFileCount,
forwardCaption: forwardCaption,
showsDateHeader: showsDateHeader,
dateHeaderText: dateHeaderText
)
var (layout, textLayout) = calculate(config: config)
// Group sender info: name on first in run, avatar on last in run.
if isGroupChat && !isOutgoing {
layout.senderKey = message.fromPublicKey
let resolvedName = DialogRepository.shared.dialogs[message.fromPublicKey]?.opponentTitle
?? String(message.fromPublicKey.prefix(8))
layout.senderName = resolvedName
// .top or .single = first message in sender run show name
layout.showsSenderName = (position == .top || position == .single)
// .bottom or .single = last message in sender run show avatar
layout.showsSenderAvatar = (position == .bottom || position == .single)
// Add height for sender name so cells don't overlap.
if layout.showsSenderName {
layout.totalHeight += 20
}
}
result[message.id] = layout
if let textLayout { textResult[message.id] = textLayout }
}
return (result, textResult)
}
}
// MARK: - Date Header Formatting (Thread-Safe)
extension MessageCellLayout {
/// Telegram-style date header text: Today / Yesterday / March 8 / March 8, 2025
static func formatDateHeader(
_ date: Date,
now: Date,
calendar: Calendar,
sameYearFormatter: DateFormatter,
diffYearFormatter: DateFormatter
) -> String {
if calendar.isDateInToday(date) {
return "Today"
}
if calendar.isDateInYesterday(date) {
return "Yesterday"
}
if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
return sameYearFormatter.string(from: date)
}
return diffYearFormatter.string(from: date)
}
}
// MARK: - Geometry Helpers
private extension CGSize {
/// Scale to fit inside `boundingSize` preserving aspect ratio (Telegram-exact sizing).
func aspectFitted(_ boundingSize: CGSize) -> CGSize {
guard width > 0, height > 0 else { return boundingSize }
let scale = min(boundingSize.width / width, boundingSize.height / height)
return CGSize(width: floor(width * scale), height: floor(height * scale))
}
}