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 } }