Files
mobile-ios/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift

578 lines
23 KiB
Swift

import UIKit
/// Universal pure UIKit message cell handles ALL message types.
/// Rosetta equivalent of Telegram's ChatMessageBubbleItemNode.
///
/// Architecture (Telegram pattern):
/// 1. `MessageCellLayout.calculate()` runs on ANY thread (background-safe)
/// 2. `NativeMessageCell.apply(layout:)` runs on main thread, just sets frames
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
///
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
// MARK: - Constants
private static let outgoingColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1, alpha: 1)
private static let incomingColor = UIColor(red: 0x2C/255.0, green: 0x2C/255.0, blue: 0x2E/255.0, alpha: 1)
private static let textFont = UIFont.systemFont(ofSize: 17, weight: .regular)
private static let timestampFont = UIFont.systemFont(ofSize: 11, weight: .regular)
private static let replyNameFont = UIFont.systemFont(ofSize: 13, weight: .semibold)
private static let replyTextFont = UIFont.systemFont(ofSize: 13, weight: .regular)
private static let forwardLabelFont = UIFont.systemFont(ofSize: 13, weight: .regular)
private static let forwardNameFont = UIFont.systemFont(ofSize: 14, weight: .semibold)
private static let fileNameFont = UIFont.systemFont(ofSize: 14, weight: .medium)
private static let fileSizeFont = UIFont.systemFont(ofSize: 12, weight: .regular)
// MARK: - Subviews (always present, hidden when unused)
// Bubble
private let bubbleView = UIView()
private let bubbleLayer = CAShapeLayer()
// Text
private let textLabel = UILabel()
// Timestamp + delivery
private let timestampLabel = UILabel()
private let checkmarkView = UIImageView()
// Reply quote
private let replyContainer = UIView()
private let replyBar = UIView()
private let replyNameLabel = UILabel()
private let replyTextLabel = UILabel()
// Photo
private let photoView = UIImageView()
private let photoPlaceholderView = UIView()
// File
private let fileContainer = UIView()
private let fileIconView = UIView()
private let fileNameLabel = UILabel()
private let fileSizeLabel = UILabel()
// Forward header
private let forwardLabel = UILabel()
private let forwardAvatarView = UIView()
private let forwardNameLabel = UILabel()
// Swipe-to-reply
private let replyIconView = UIImageView()
// MARK: - State
private var message: ChatMessage?
private var actions: MessageCellActions?
private var currentLayout: MessageCellLayout?
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupViews() {
contentView.backgroundColor = .clear
backgroundColor = .clear
contentView.transform = CGAffineTransform(scaleX: 1, y: -1) // inverted scroll flip
// Bubble
bubbleLayer.fillColor = Self.outgoingColor.cgColor
bubbleView.layer.insertSublayer(bubbleLayer, at: 0)
contentView.addSubview(bubbleView)
// Text
textLabel.font = Self.textFont
textLabel.textColor = .white
textLabel.numberOfLines = 0
textLabel.lineBreakMode = .byWordWrapping
bubbleView.addSubview(textLabel)
// Timestamp
timestampLabel.font = Self.timestampFont
bubbleView.addSubview(timestampLabel)
// Checkmark
checkmarkView.contentMode = .scaleAspectFit
bubbleView.addSubview(checkmarkView)
// Reply quote
replyBar.layer.cornerRadius = 1.5
replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont
replyContainer.addSubview(replyNameLabel)
replyTextLabel.font = Self.replyTextFont
replyTextLabel.lineBreakMode = .byTruncatingTail
replyContainer.addSubview(replyTextLabel)
bubbleView.addSubview(replyContainer)
// Photo
photoView.contentMode = .scaleAspectFill
photoView.clipsToBounds = true
bubbleView.addSubview(photoView)
photoPlaceholderView.backgroundColor = UIColor.white.withAlphaComponent(0.1)
bubbleView.addSubview(photoPlaceholderView)
// File
fileIconView.backgroundColor = UIColor.white.withAlphaComponent(0.2)
fileIconView.layer.cornerRadius = 20
fileContainer.addSubview(fileIconView)
fileNameLabel.font = Self.fileNameFont
fileNameLabel.textColor = .white
fileContainer.addSubview(fileNameLabel)
fileSizeLabel.font = Self.fileSizeFont
fileSizeLabel.textColor = UIColor.white.withAlphaComponent(0.6)
fileContainer.addSubview(fileSizeLabel)
bubbleView.addSubview(fileContainer)
// Forward header
forwardLabel.font = Self.forwardLabelFont
forwardLabel.text = "Forwarded message"
forwardLabel.textColor = UIColor.white.withAlphaComponent(0.6)
bubbleView.addSubview(forwardLabel)
forwardAvatarView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
forwardAvatarView.layer.cornerRadius = 10
bubbleView.addSubview(forwardAvatarView)
forwardNameLabel.font = Self.forwardNameFont
forwardNameLabel.textColor = .white
bubbleView.addSubview(forwardNameLabel)
// Swipe reply icon
replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
.withRenderingMode(.alwaysTemplate)
replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
// Interactions
let contextMenu = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(contextMenu)
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
pan.delegate = self
contentView.addGestureRecognizer(pan)
}
// MARK: - Configure + Apply Layout
/// Configure cell data (content). Does NOT trigger layout.
func configure(
message: ChatMessage,
timestamp: String,
actions: MessageCellActions,
replyName: String? = nil,
replyText: String? = nil,
forwardSenderName: String? = nil
) {
self.message = message
self.actions = actions
let isOutgoing = currentLayout?.isOutgoing ?? false
// Text (filter garbage/encrypted UIKit path parity with SwiftUI)
textLabel.text = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text
// Timestamp
timestampLabel.text = timestamp
timestampLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.55)
: UIColor.white.withAlphaComponent(0.6)
// Delivery
if isOutgoing {
checkmarkView.isHidden = false
switch message.deliveryStatus {
case .delivered:
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate)
checkmarkView.tintColor = message.isRead ? .white : UIColor.white.withAlphaComponent(0.55)
case .waiting:
checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate)
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55)
case .error:
checkmarkView.image = UIImage(systemName: "exclamationmark.circle")?.withRenderingMode(.alwaysTemplate)
checkmarkView.tintColor = .red
}
} else {
checkmarkView.isHidden = true
}
// Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
// Reply quote
if let replyName {
replyContainer.isHidden = false
replyBar.backgroundColor = isOutgoing ? .white : Self.outgoingColor
replyNameLabel.text = replyName
replyNameLabel.textColor = isOutgoing ? .white : Self.outgoingColor
replyTextLabel.text = replyText ?? ""
replyTextLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.8)
: UIColor.white.withAlphaComponent(0.6)
} else {
replyContainer.isHidden = true
}
// Forward
if let forwardSenderName {
forwardLabel.isHidden = false
forwardAvatarView.isHidden = false
forwardNameLabel.isHidden = false
forwardNameLabel.text = forwardSenderName
} else {
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
}
// Photo placeholder (actual image loading handled separately)
photoView.isHidden = !(currentLayout?.hasPhoto ?? false)
photoPlaceholderView.isHidden = !(currentLayout?.hasPhoto ?? false)
// File
if let layout = currentLayout, layout.hasFile {
fileContainer.isHidden = false
let fileAtt = message.attachments.first { $0.type == .file }
fileNameLabel.text = fileAtt?.preview.components(separatedBy: "::").last ?? "File"
fileSizeLabel.text = ""
} else {
fileContainer.isHidden = true
}
}
/// Apply pre-calculated layout (main thread only just sets frames).
/// This is the "apply" part of Telegram's asyncLayout pattern.
/// NOTE: Bubble X-position is recalculated in layoutSubviews() based on actual cell width.
func apply(layout: MessageCellLayout) {
currentLayout = layout
setNeedsLayout() // trigger layoutSubviews for correct X positioning
}
override func layoutSubviews() {
super.layoutSubviews()
guard let layout = currentLayout else { return }
let cellW = contentView.bounds.width
let tailW: CGFloat = layout.hasTail ? 6 : 0
let isTopOrSingle = (layout.position == .single || layout.position == .top)
let topPad: CGFloat = isTopOrSingle ? 6 : 2
// Bubble X: align to RIGHT for outgoing, LEFT for incoming
// This is computed from CELL WIDTH, not maxBubbleWidth
let bubbleX: CGFloat
if layout.isOutgoing {
bubbleX = cellW - layout.bubbleSize.width - tailW - 2
} else {
bubbleX = tailW + 2
}
bubbleView.frame = CGRect(
x: bubbleX, y: topPad,
width: layout.bubbleSize.width, height: layout.bubbleSize.height
)
bubbleLayer.frame = bubbleView.bounds
let shapeRect: CGRect
if layout.hasTail {
if layout.isOutgoing {
shapeRect = CGRect(x: 0, y: 0,
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
} else {
shapeRect = CGRect(x: -6, y: 0,
width: layout.bubbleSize.width + 6, height: layout.bubbleSize.height)
}
} else {
shapeRect = CGRect(origin: .zero, size: layout.bubbleSize)
}
bubbleLayer.path = BubblePathCache.shared.path(
size: shapeRect.size, origin: shapeRect.origin,
position: layout.position, isOutgoing: layout.isOutgoing, hasTail: layout.hasTail
)
// Text
textLabel.isHidden = layout.textSize == .zero
textLabel.frame = layout.textFrame
// Timestamp + checkmark
timestampLabel.frame = layout.timestampFrame
checkmarkView.frame = layout.checkmarkFrame
// Reply
replyContainer.isHidden = !layout.hasReplyQuote
if layout.hasReplyQuote {
replyContainer.frame = layout.replyContainerFrame
replyBar.frame = layout.replyBarFrame
replyNameLabel.frame = layout.replyNameFrame
replyTextLabel.frame = layout.replyTextFrame
}
// Photo
photoView.isHidden = !layout.hasPhoto
photoPlaceholderView.isHidden = !layout.hasPhoto
if layout.hasPhoto {
photoView.frame = layout.photoFrame
photoPlaceholderView.frame = layout.photoFrame
}
// File
fileContainer.isHidden = !layout.hasFile
if layout.hasFile {
fileContainer.frame = layout.fileFrame
fileIconView.frame = CGRect(x: 10, y: 8, width: 40, height: 40)
fileNameLabel.frame = CGRect(x: 60, y: 10, width: layout.fileFrame.width - 70, height: 17)
fileSizeLabel.frame = CGRect(x: 60, y: 30, width: layout.fileFrame.width - 70, height: 15)
}
// Forward
if layout.isForward {
forwardLabel.frame = layout.forwardHeaderFrame
forwardAvatarView.frame = layout.forwardAvatarFrame
forwardNameLabel.frame = layout.forwardNameFrame
}
// Reply icon (for swipe gesture) use actual bubbleView frame
replyIconView.frame = CGRect(
x: layout.isOutgoing
? bubbleView.frame.minX - 30
: bubbleView.frame.maxX + tailW + 8,
y: bubbleView.frame.midY - 10,
width: 20, height: 20
)
}
// MARK: - Self-sizing (from pre-calculated layout)
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
// Always return concrete height never fall to super (expensive self-sizing)
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = currentLayout?.totalHeight ?? 50
return attrs
}
// MARK: - Context Menu
func contextMenuInteraction(
_ interaction: UIContextMenuInteraction,
configurationForMenuAtLocation location: CGPoint
) -> UIContextMenuConfiguration? {
guard let message, let actions else { return nil }
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
var items: [UIAction] = []
if !message.text.isEmpty {
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
actions.onCopy(message.text)
})
}
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
actions.onReply(message)
})
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
actions.onForward(message)
})
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
actions.onDelete(message)
})
return UIMenu(children: items)
}
}
// MARK: - Swipe to Reply
@objc private func handleSwipe(_ gesture: UIPanGestureRecognizer) {
let translation = gesture.translation(in: contentView)
let isOutgoing = currentLayout?.isOutgoing ?? false
switch gesture.state {
case .changed:
let dx = isOutgoing ? min(translation.x, 0) : max(translation.x, 0)
let clamped = isOutgoing ? max(dx, -60) : min(dx, 60)
bubbleView.transform = CGAffineTransform(translationX: clamped, y: 0)
let progress = min(abs(clamped) / 50, 1)
replyIconView.alpha = progress
replyIconView.transform = CGAffineTransform(scaleX: progress, y: progress)
case .ended, .cancelled:
if abs(translation.x) > 50, let message, let actions {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
actions.onReply(message)
}
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseOut) {
self.bubbleView.transform = .identity
self.replyIconView.alpha = 0
self.replyIconView.transform = .identity
}
default:
break
}
}
// MARK: - Reuse
override func prepareForReuse() {
super.prepareForReuse()
message = nil
actions = nil
currentLayout = nil
textLabel.text = nil
timestampLabel.text = nil
checkmarkView.image = nil
photoView.image = nil
replyContainer.isHidden = true
fileContainer.isHidden = true
forwardLabel.isHidden = true
forwardAvatarView.isHidden = true
forwardNameLabel.isHidden = true
photoView.isHidden = true
photoPlaceholderView.isHidden = true
bubbleView.transform = .identity
replyIconView.alpha = 0
}
}
// MARK: - UIGestureRecognizerDelegate
extension NativeMessageCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
return abs(velocity.x) > abs(velocity.y) * 1.5
}
}
// MARK: - Bubble Path Cache
/// Caches CGPath objects for bubble shapes to avoid recalculating Bezier paths every frame.
/// Telegram equivalent: PrincipalThemeEssentialGraphics caches bubble images.
final class BubblePathCache {
static let shared = BubblePathCache()
private var cache: [String: CGPath] = [:]
func path(
size: CGSize, origin: CGPoint,
position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath {
let key = "\(Int(size.width))x\(Int(size.height))_\(Int(origin.x))_\(position)_\(isOutgoing)_\(hasTail)"
if let cached = cache[key] { return cached }
let rect = CGRect(origin: origin, size: size)
let path = makeBubblePath(in: rect, position: position, isOutgoing: isOutgoing, hasTail: hasTail)
cache[key] = path
// Evict if cache grows too large
if cache.count > 200 {
cache.removeAll()
}
return path
}
private func makeBubblePath(
in rect: CGRect, position: BubblePosition, isOutgoing: Bool, hasTail: Bool
) -> CGPath {
let r: CGFloat = 18, s: CGFloat = 8, tailW: CGFloat = 6
// Body rect
let bodyRect: CGRect
if hasTail {
bodyRect = isOutgoing
? CGRect(x: rect.minX, y: rect.minY, width: rect.width - tailW, height: rect.height)
: CGRect(x: rect.minX + tailW, y: rect.minY, width: rect.width - tailW, height: rect.height)
} else {
bodyRect = rect
}
// Corner radii
let (tl, tr, bl, br): (CGFloat, CGFloat, CGFloat, CGFloat) = {
switch position {
case .single: return (r, r, r, r)
case .top: return isOutgoing ? (r, r, r, s) : (r, r, s, r)
case .mid: return isOutgoing ? (r, s, r, s) : (s, r, s, r)
case .bottom: return isOutgoing ? (r, s, r, r) : (s, r, r, r)
}
}()
let maxR = min(bodyRect.width, bodyRect.height) / 2
let cTL = min(tl, maxR), cTR = min(tr, maxR)
let cBL = min(bl, maxR), cBR = min(br, maxR)
let path = CGMutablePath()
// Rounded rect body
path.move(to: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY))
path.addLine(to: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY))
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY),
tangent2End: CGPoint(x: bodyRect.maxX, y: bodyRect.minY + cTR), radius: cTR)
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
path.addArc(tangent1End: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY),
tangent2End: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY), radius: cBR)
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY),
tangent2End: CGPoint(x: bodyRect.minX, y: bodyRect.maxY - cBL), radius: cBL)
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
path.addArc(tangent1End: CGPoint(x: bodyRect.minX, y: bodyRect.minY),
tangent2End: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY), radius: cTL)
path.closeSubpath()
// Figma SVG tail
if hasTail {
addFigmaTail(to: path, bodyRect: bodyRect, isOutgoing: isOutgoing)
}
return path
}
private func addFigmaTail(to path: CGMutablePath, bodyRect: CGRect, isOutgoing: Bool) {
let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305
let sc: CGFloat = 6 / svgStraightX
let tailH = svgMaxY * sc
let bodyEdge = isOutgoing ? bodyRect.maxX : bodyRect.minX
let bottom = bodyRect.maxY
let top = bottom - tailH
let dir: CGFloat = isOutgoing ? 1 : -1
func tp(_ svgX: CGFloat, _ svgY: CGFloat) -> CGPoint {
let dx = (svgStraightX - svgX) * sc * dir
return CGPoint(x: bodyEdge + dx, y: top + svgY * sc)
}
if isOutgoing {
path.move(to: tp(5.59961, 24.2305))
path.addCurve(to: tp(0, 33.0244), control1: tp(5.42042, 28.0524), control2: tp(3.19779, 31.339))
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(0.851596, 33.1596), control2: tp(1.72394, 33.2305))
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(6.53776, 33.2305), control2: tp(10.1517, 31.8599))
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(10.7434, 27.898), control2: tp(8.86922, 25.7134))
path.addCurve(to: tp(5.6123, 4.2002), control1: tp(5.61235, 19.3215), control2: tp(5.6123, 14.281))
path.addLine(to: tp(5.6123, 0))
path.addLine(to: tp(5.59961, 0))
path.addLine(to: tp(5.59961, 24.2305))
path.closeSubpath()
} else {
path.move(to: tp(5.59961, 24.2305))
path.addLine(to: tp(5.59961, 0))
path.addLine(to: tp(5.6123, 0))
path.addLine(to: tp(5.6123, 4.2002))
path.addCurve(to: tp(7.57422, 23.1719), control1: tp(5.6123, 14.281), control2: tp(5.61235, 19.3215))
path.addCurve(to: tp(13.0293, 29.5596), control1: tp(8.86922, 25.7134), control2: tp(10.7434, 27.898))
path.addCurve(to: tp(2.6123, 33.2305), control1: tp(10.1517, 31.8599), control2: tp(6.53776, 33.2305))
path.addCurve(to: tp(0, 33.0244), control1: tp(1.72394, 33.2305), control2: tp(0.851596, 33.1596))
path.addCurve(to: tp(5.59961, 24.2305), control1: tp(3.19779, 31.339), control2: tp(5.42042, 28.0524))
path.closeSubpath()
}
}
}