1027 lines
45 KiB
Swift
1027 lines
45 KiB
Swift
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))
|
||
}
|
||
}
|