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

492 lines
19 KiB
Swift

import UIKit
/// Pure UIKit message cell for text messages (with optional reply quote).
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
// MARK: - Constants
private static let mainRadius: CGFloat = 18
private static let smallRadius: CGFloat = 8
private static let tailProtrusion: CGFloat = 6
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 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 replyQuoteHeight: CGFloat = 41
// MARK: - Subviews
private let bubbleView = UIView()
private let bubbleLayer = CAShapeLayer()
private let textLabel = UILabel()
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()
// Swipe-to-reply
private let replyIconView = UIImageView()
private var swipeStartX: CGFloat = 0
// MARK: - State
private var message: ChatMessage?
private var actions: MessageCellActions?
private var isOutgoing = false
private var position: BubblePosition = .single
private var hasReplyQuote = false
private var preCalculatedHeight: CGFloat?
// 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
// Flip for inverted scroll (scale, NOT rotation rotation flips text)
contentView.transform = CGAffineTransform(scaleX: 1, y: -1)
// 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
timestampLabel.textColor = UIColor.white.withAlphaComponent(0.55)
bubbleView.addSubview(timestampLabel)
// Checkmark
checkmarkView.contentMode = .scaleAspectFit
checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55)
bubbleView.addSubview(checkmarkView)
// Reply quote
replyBar.backgroundColor = .white
replyBar.layer.cornerRadius = 1.5
replyContainer.addSubview(replyBar)
replyNameLabel.font = Self.replyNameFont
replyNameLabel.textColor = .white
replyContainer.addSubview(replyNameLabel)
replyTextLabel.font = Self.replyTextFont
replyTextLabel.textColor = UIColor.white.withAlphaComponent(0.8)
replyTextLabel.lineBreakMode = .byTruncatingTail
replyContainer.addSubview(replyTextLabel)
replyContainer.isHidden = true
bubbleView.addSubview(replyContainer)
// Swipe reply icon (hidden, shown during swipe)
replyIconView.image = UIImage(systemName: "arrowshape.turn.up.left.fill")?
.withRenderingMode(.alwaysTemplate)
replyIconView.tintColor = UIColor.white.withAlphaComponent(0.5)
replyIconView.alpha = 0
contentView.addSubview(replyIconView)
// Context menu
let contextMenu = UIContextMenuInteraction(delegate: self)
bubbleView.addInteraction(contextMenu)
// Swipe-to-reply gesture
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
pan.delegate = self
contentView.addGestureRecognizer(pan)
}
// MARK: - Configure
func configure(
message: ChatMessage,
timestamp: String,
isOutgoing: Bool,
position: BubblePosition,
actions: MessageCellActions,
replyName: String? = nil,
replyText: String? = nil
) {
self.message = message
self.actions = actions
self.isOutgoing = isOutgoing
self.position = position
// Text
textLabel.text = message.text
textLabel.textColor = .white
// Timestamp
timestampLabel.text = timestamp
timestampLabel.textColor = isOutgoing
? UIColor.white.withAlphaComponent(0.55)
: UIColor.white.withAlphaComponent(0.6)
// Delivery indicator
if isOutgoing {
checkmarkView.isHidden = false
switch message.deliveryStatus {
case .delivered:
checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate)
checkmarkView.tintColor = message.isRead
? UIColor.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 = UIColor.red
}
} else {
checkmarkView.isHidden = true
}
// Bubble color
bubbleLayer.fillColor = (isOutgoing ? Self.outgoingColor : Self.incomingColor).cgColor
// Reply quote
hasReplyQuote = (replyName != nil)
replyContainer.isHidden = !hasReplyQuote
if hasReplyQuote {
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)
}
setNeedsLayout()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let bounds = contentView.bounds
let hasTail = (position == .single || position == .bottom)
let isTopOrSingle = (position == .single || position == .top)
let topPad: CGFloat = isTopOrSingle ? 6 : 2
let tailW = hasTail ? Self.tailProtrusion : 0
// Text measurement
let tsTrailing: CGFloat = isOutgoing ? 53 : 37
let textMaxW = bounds.width - 11 - tsTrailing - tailW - 8
let textSize = textLabel.sizeThatFits(CGSize(width: max(textMaxW, 50), height: .greatestFiniteMagnitude))
// Bubble dimensions
let bubbleContentW = 11 + textSize.width + tsTrailing
let minW: CGFloat = isOutgoing ? 86 : 66
let bubbleW = max(min(bubbleContentW, bounds.width - tailW - 4), minW)
let replyH: CGFloat = hasReplyQuote ? Self.replyQuoteHeight + 5 : 0
let bubbleH = replyH + textSize.height + 10 // 5pt top + 5pt bottom
// Bubble X position
let bubbleX: CGFloat
if isOutgoing {
bubbleX = bounds.width - bubbleW - tailW - 2
} else {
bubbleX = tailW + 2
}
bubbleView.frame = CGRect(x: bubbleX, y: topPad, width: bubbleW, height: bubbleH)
// Bubble shape with tail
let shapeRect: CGRect
if hasTail {
if isOutgoing {
shapeRect = CGRect(x: 0, y: 0, width: bubbleW + tailW, height: bubbleH)
} else {
shapeRect = CGRect(x: -tailW, y: 0, width: bubbleW + tailW, height: bubbleH)
}
} else {
shapeRect = CGRect(x: 0, y: 0, width: bubbleW, height: bubbleH)
}
bubbleLayer.path = makeBubblePath(in: shapeRect, hasTail: hasTail).cgPath
bubbleLayer.frame = bubbleView.bounds
// Reply quote layout
if hasReplyQuote {
let rX: CGFloat = 5
replyContainer.frame = CGRect(x: rX, y: 5, width: bubbleW - 10, height: Self.replyQuoteHeight)
replyBar.frame = CGRect(x: 0, y: 0, width: 3, height: Self.replyQuoteHeight)
replyNameLabel.frame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17)
replyTextLabel.frame = CGRect(x: 9, y: 20, width: bubbleW - 24, height: 17)
}
// Text
let textY: CGFloat = hasReplyQuote ? Self.replyQuoteHeight + 10 : 5
textLabel.frame = CGRect(x: 11, y: textY, width: textSize.width, height: textSize.height)
// Timestamp + checkmark
let tsSize = timestampLabel.sizeThatFits(CGSize(width: 60, height: 20))
let checkW: CGFloat = isOutgoing ? 14 : 0
timestampLabel.frame = CGRect(
x: bubbleW - tsSize.width - checkW - 11,
y: bubbleH - tsSize.height - 5,
width: tsSize.width, height: tsSize.height
)
if isOutgoing {
checkmarkView.frame = CGRect(
x: bubbleW - 11 - 10,
y: bubbleH - tsSize.height - 4,
width: 10, height: 10
)
}
// Swipe reply icon (off-screen right of bubble)
replyIconView.frame = CGRect(
x: isOutgoing ? bubbleX - 30 : bubbleX + bubbleW + tailW + 8,
y: topPad + bubbleH / 2 - 10,
width: 20, height: 20
)
}
// MARK: - Bubble Path (Figma-accurate with SVG tail)
private func makeBubblePath(in rect: CGRect, hasTail: Bool) -> UIBezierPath {
let r = Self.mainRadius
let s = Self.smallRadius
// Body rect
let bodyRect: CGRect
if hasTail {
if isOutgoing {
bodyRect = CGRect(x: rect.minX, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
} else {
bodyRect = CGRect(x: rect.minX + Self.tailProtrusion, y: rect.minY,
width: rect.width - Self.tailProtrusion, height: rect.height)
}
} else {
bodyRect = rect
}
// Corner radii per position
let (tl, tr, bl, br) = cornerRadii(r: r, s: s)
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 = UIBezierPath()
// 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(withCenter: CGPoint(x: bodyRect.maxX - cTR, y: bodyRect.minY + cTR),
radius: cTR, startAngle: -.pi/2, endAngle: 0, clockwise: true)
path.addLine(to: CGPoint(x: bodyRect.maxX, y: bodyRect.maxY - cBR))
path.addArc(withCenter: CGPoint(x: bodyRect.maxX - cBR, y: bodyRect.maxY - cBR),
radius: cBR, startAngle: 0, endAngle: .pi/2, clockwise: true)
path.addLine(to: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY))
path.addArc(withCenter: CGPoint(x: bodyRect.minX + cBL, y: bodyRect.maxY - cBL),
radius: cBL, startAngle: .pi/2, endAngle: .pi, clockwise: true)
path.addLine(to: CGPoint(x: bodyRect.minX, y: bodyRect.minY + cTL))
path.addArc(withCenter: CGPoint(x: bodyRect.minX + cTL, y: bodyRect.minY + cTL),
radius: cTL, startAngle: .pi, endAngle: -.pi/2, clockwise: true)
path.close()
// Figma SVG tail (exact port from BubbleTailShape.swift)
if hasTail {
addFigmaTail(to: path, bodyRect: bodyRect)
}
return path
}
/// Port of BubbleTailShape.addTail exact Figma SVG curves.
private func addFigmaTail(to path: UIBezierPath, bodyRect: CGRect) {
let svgStraightX: CGFloat = 5.59961
let svgMaxY: CGFloat = 33.2305
let sc = Self.tailProtrusion / 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),
controlPoint1: tp(5.42042, 28.0524), controlPoint2: tp(3.19779, 31.339))
path.addCurve(to: tp(2.6123, 33.2305),
controlPoint1: tp(0.851596, 33.1596), controlPoint2: tp(1.72394, 33.2305))
path.addCurve(to: tp(13.0293, 29.5596),
controlPoint1: tp(6.53776, 33.2305), controlPoint2: tp(10.1517, 31.8599))
path.addCurve(to: tp(7.57422, 23.1719),
controlPoint1: tp(10.7434, 27.898), controlPoint2: tp(8.86922, 25.7134))
path.addCurve(to: tp(5.6123, 4.2002),
controlPoint1: tp(5.61235, 19.3215), controlPoint2: 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.close()
} 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),
controlPoint1: tp(5.6123, 14.281), controlPoint2: tp(5.61235, 19.3215))
path.addCurve(to: tp(13.0293, 29.5596),
controlPoint1: tp(8.86922, 25.7134), controlPoint2: tp(10.7434, 27.898))
path.addCurve(to: tp(2.6123, 33.2305),
controlPoint1: tp(10.1517, 31.8599), controlPoint2: tp(6.53776, 33.2305))
path.addCurve(to: tp(0, 33.0244),
controlPoint1: tp(1.72394, 33.2305), controlPoint2: tp(0.851596, 33.1596))
path.addCurve(to: tp(5.59961, 24.2305),
controlPoint1: tp(3.19779, 31.339), controlPoint2: tp(5.42042, 28.0524))
path.close()
}
}
private func cornerRadii(r: CGFloat, s: CGFloat)
-> (tl: CGFloat, tr: CGFloat, bl: CGFloat, br: 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)
}
}
// 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] = []
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)
switch gesture.state {
case .began:
swipeStartX = bubbleView.frame.origin.x
case .changed:
// Only allow swipe toward reply direction
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:
let dx = isOutgoing ? translation.x : translation.x
if abs(dx) > 50, let message, let actions {
// Haptic
let generator = UIImpactFeedbackGenerator(style: .medium)
generator.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: - Self-sizing
override func preferredLayoutAttributesFitting(
_ layoutAttributes: UICollectionViewLayoutAttributes
) -> UICollectionViewLayoutAttributes {
if let h = preCalculatedHeight {
let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
attrs.size.height = h
return attrs
}
// Fallback: use automatic sizing
return super.preferredLayoutAttributesFitting(layoutAttributes)
}
func setPreCalculatedHeight(_ height: CGFloat?) {
preCalculatedHeight = height
}
override func prepareForReuse() {
super.prepareForReuse()
preCalculatedHeight = nil
message = nil
actions = nil
textLabel.text = nil
timestampLabel.text = nil
checkmarkView.image = nil
replyContainer.isHidden = true
bubbleView.transform = .identity
replyIconView.alpha = 0
}
}
// MARK: - UIGestureRecognizerDelegate
extension NativeTextBubbleCell: UIGestureRecognizerDelegate {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true }
let velocity = pan.velocity(in: contentView)
// Only horizontal swipes (don't interfere with scroll)
return abs(velocity.x) > abs(velocity.y) * 1.5
}
}