diff --git a/.gitignore b/.gitignore index ed7f004..b629bd8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ CLAUDE.md .claude.local.md desktop server +Telegram-iOS AGENTS.md # Xcode diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 36e2ebd..4b17e4b 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -445,6 +445,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Rosetta/Rosetta-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; @@ -484,6 +485,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Rosetta/Rosetta-Bridging-Header.h"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = 1; diff --git a/Rosetta/Core/Layout/MessageCellLayout.swift b/Rosetta/Core/Layout/MessageCellLayout.swift new file mode 100644 index 0000000..73ebe30 --- /dev/null +++ b/Rosetta/Core/Layout/MessageCellLayout.swift @@ -0,0 +1,504 @@ +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 + + let totalHeight: 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 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 checkmarkFrame: CGRect // Checkmark icon frame in bubble coords + + // 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: - 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 text: String + let hasReplyQuote: Bool + let replyName: String? + let replyText: String? + let imageCount: Int + let fileCount: Int + let avatarCount: Int + let isForward: Bool + let forwardImageCount: Int + let forwardFileCount: Int + let forwardCaption: String? + } + + /// Calculate complete cell layout on ANY thread. + /// Uses CoreText for text measurement (thread-safe). + /// Returns layout with all frame rects ready for main-thread application. + /// + /// 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) -> MessageCellLayout { + let font = UIFont.systemFont(ofSize: 17, weight: .regular) + let tsFont = UIFont.systemFont(ofSize: 11, weight: .regular) + + let hasTail = (config.position == .single || config.position == .bottom) + let isTopOrSingle = (config.position == .single || config.position == .top) + let topPad: CGFloat = isTopOrSingle ? 6 : 2 + let tailW: CGFloat = hasTail ? 6 : 0 + + // Determine 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 { + messageType = .file + } else if config.hasReplyQuote { + messageType = .textWithReply + } else { + messageType = .text + } + + // Status (timestamp + checkmark) measurement + let tsSize = measureText("00:00", maxWidth: 60, font: tsFont) + let checkW: CGFloat = config.isOutgoing ? 14 : 0 + let statusGap: CGFloat = 8 // minimum gap between trailing text and status + let statusWidth = tsSize.width + checkW + statusGap + + // Side padding inside bubble + let leftPad: CGFloat = 11 + let rightPad: CGFloat = 11 + + // Text measurement at FULL width (no timestamp reservation — Telegram pattern) + let fullTextMaxW = config.maxBubbleWidth - leftPad - rightPad - tailW - 4 + let isTextMessage = (messageType == .text || messageType == .textWithReply) + let textMeasurement: TextMeasurement + if !config.text.isEmpty && isTextMessage { + textMeasurement = measureTextDetailed(config.text, maxWidth: max(fullTextMaxW, 50), font: font) + } else if !config.text.isEmpty { + // Photo captions, forwards, files — use old fixed-trailing approach + let tsTrailing: CGFloat = config.isOutgoing ? 53 : 37 + let textMaxW = config.maxBubbleWidth - leftPad - tsTrailing - tailW - 8 + let size = measureText(config.text, maxWidth: max(textMaxW, 50), font: font) + textMeasurement = TextMeasurement(size: size, trailingLineWidth: size.width) + } else { + textMeasurement = TextMeasurement(size: .zero, trailingLineWidth: 0) + } + + // Determine if timestamp fits inline with last text line (Telegram algorithm) + let timestampInline: Bool + let extraStatusH: CGFloat + if isTextMessage && !config.text.isEmpty { + if textMeasurement.trailingLineWidth + statusWidth <= fullTextMaxW { + timestampInline = true + extraStatusH = 0 + } else { + timestampInline = false + extraStatusH = tsSize.height + 2 + } + } else { + timestampInline = true // non-text messages: status overlays + extraStatusH = 0 + } + + // Reply quote + let replyH: CGFloat = config.hasReplyQuote ? 46 : 0 + + // Photo collage + var photoH: CGFloat = 0 + if config.imageCount > 0 { + photoH = Self.collageHeight(count: config.imageCount, width: config.maxBubbleWidth - 8) + } + + // Forward + let forwardHeaderH: CGFloat = config.isForward ? 40 : 0 + + // File + let fileH: CGFloat = CGFloat(config.fileCount) * 56 + + // Bubble width — tight for text messages (Telegram pattern) + let minW: CGFloat = config.isOutgoing ? 86 : 66 + var bubbleW: CGFloat + + if config.imageCount > 0 { + // Photos: full width + bubbleW = config.maxBubbleWidth - tailW - 4 + } else if isTextMessage && !config.text.isEmpty { + // Tight bubble: just fits content + inline/new-line status + let contentW: CGFloat + if timestampInline { + contentW = max(textMeasurement.size.width, + textMeasurement.trailingLineWidth + statusWidth) + } else { + contentW = max(textMeasurement.size.width, statusWidth) + } + bubbleW = min(contentW + leftPad + rightPad, config.maxBubbleWidth - tailW - 4) + // Reply quote needs minimum width + if config.hasReplyQuote { + bubbleW = max(bubbleW, 180) + } + } else { + // Fallback for non-text: old approach + let tsTrailing: CGFloat = config.isOutgoing ? 53 : 37 + let bubbleContentW = leftPad + textMeasurement.size.width + tsTrailing + bubbleW = min(bubbleContentW, config.maxBubbleWidth - tailW - 4) + } + bubbleW = max(bubbleW, minW) + + // Bubble height + var bubbleH: CGFloat = 0 + bubbleH += replyH + bubbleH += forwardHeaderH + bubbleH += photoH + bubbleH += fileH + if !config.text.isEmpty { + bubbleH += textMeasurement.size.height + 10 // 5pt top + 5pt bottom + bubbleH += extraStatusH // 0 if inline, ~15pt if new line + if photoH > 0 { bubbleH += 6 } // caption padding + } + if config.text.isEmpty && photoH == 0 && fileH == 0 && !config.isForward { + bubbleH = max(bubbleH, 36) // minimum + } + + // Total height + let totalH = topPad + bubbleH + (hasTail ? 6 : 0) + + // Bubble frame (X computed from cell width in layoutSubviews, this is approximate) + let bubbleX: CGFloat + if config.isOutgoing { + bubbleX = config.maxBubbleWidth - bubbleW - tailW + 10 - 2 + } else { + bubbleX = tailW + 10 + 2 + } + let bubbleFrame = CGRect(x: bubbleX, y: topPad, width: bubbleW, height: bubbleH) + + // Text frame (in bubble coords) + var textY: CGFloat = 5 + if config.hasReplyQuote { textY = replyH } + if forwardHeaderH > 0 { textY = forwardHeaderH } + if photoH > 0 { + textY = photoH + 6 + if config.hasReplyQuote { textY = replyH + photoH + 6 } + } + if fileH > 0 { textY = fileH + (config.hasReplyQuote ? replyH : 0) } + let textFrame = CGRect(x: leftPad, y: textY, + width: textMeasurement.size.width, height: textMeasurement.size.height) + + // Timestamp + checkmark frames (always bottom-right of bubble) + let tsFrame = CGRect( + x: bubbleW - tsSize.width - checkW - rightPad, + y: bubbleH - tsSize.height - 5, + width: tsSize.width, height: tsSize.height + ) + let checkFrame = CGRect( + x: bubbleW - rightPad - 10, + y: bubbleH - tsSize.height - 4, + width: 10, height: 10 + ) + + // Reply frames + let replyContainerFrame = CGRect(x: 5, y: 5, width: bubbleW - 10, height: 41) + let replyBarFrame = CGRect(x: 0, y: 0, width: 3, height: 41) + let replyNameFrame = CGRect(x: 9, y: 2, width: bubbleW - 24, height: 17) + let replyTextFrame = CGRect(x: 9, y: 20, width: bubbleW - 24, height: 17) + + // Photo frame + let photoFrame = CGRect(x: 2, y: config.hasReplyQuote ? replyH : 0, width: bubbleW - 4, height: photoH) + + // File frame + let fileFrame = CGRect(x: 0, y: config.hasReplyQuote ? replyH : 0, width: bubbleW, height: fileH) + + // Forward frames + let fwdHeaderFrame = CGRect(x: 10, y: 6, width: bubbleW - 20, height: 14) + let fwdAvatarFrame = CGRect(x: 10, y: 23, width: 20, height: 20) + let fwdNameFrame = CGRect(x: 34, y: 24, width: bubbleW - 44, height: 17) + + return MessageCellLayout( + totalHeight: totalH, + isOutgoing: config.isOutgoing, + position: config.position, + messageType: messageType, + bubbleFrame: bubbleFrame, + bubbleSize: CGSize(width: bubbleW, height: bubbleH), + hasTail: hasTail, + textFrame: textFrame, + textSize: textMeasurement.size, + timestampInline: timestampInline, + timestampFrame: tsFrame, + checkmarkFrame: checkFrame, + 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, + fileFrame: fileFrame, + isForward: config.isForward, + forwardHeaderFrame: fwdHeaderFrame, + forwardAvatarFrame: fwdAvatarFrame, + forwardNameFrame: fwdNameFrame + ) + } + + // MARK: - Collage Height (Thread-Safe) + + /// Photo collage height — same formulas as C++ MessageLayout & PhotoCollageView.swift. + private static func collageHeight(count: Int, width: CGFloat) -> CGFloat { + guard count > 0 else { return 0 } + if count == 1 { return min(width * 0.75, 320) } + if count == 2 { + let cellW = (width - 2) / 2 + return min(cellW * 1.2, 320) + } + if count == 3 { + let leftW = width * 0.66 + return min(leftW * 1.1, 320) + } + if count == 4 { + let cellW = (width - 2) / 2 + let cellH = min(cellW * 0.85, 160) + return cellH * 2 + 2 + } + // 5+ + let topH = min(width / 2 * 0.85, 176) + let botH = min(width / 3 * 0.85, 144) + return topH + 2 + botH + } + + // 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 + } + + /// CoreText detailed text measurement — returns both overall size and trailing line width. + /// Uses CTFramesetter + CTFrame (thread-safe) for per-line width analysis. + /// This enables Telegram-style inline timestamp positioning. + private static func measureTextDetailed( + _ text: String, maxWidth: CGFloat, font: UIFont + ) -> TextMeasurement { + guard !text.isEmpty else { + return TextMeasurement(size: .zero, trailingLineWidth: 0) + } + + let attrs: [NSAttributedString.Key: Any] = [.font: font] + let attrStr = CFAttributedStringCreate( + nil, text as CFString, + attrs as CFDictionary + )! + let framesetter = CTFramesetterCreateWithAttributedString(attrStr) + + // Create frame for text layout + let path = CGPath( + rect: CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude), + transform: nil + ) + let frame = CTFramesetterCreateFrame( + framesetter, CFRange(location: 0, length: 0), path, nil + ) + + let lines = CTFrameGetLines(frame) as! [CTLine] + guard !lines.isEmpty else { + return TextMeasurement(size: .zero, trailingLineWidth: 0) + } + + // Get max line width and last line width + var maxLineWidth: CGFloat = 0 + for line in lines { + let lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil)) + maxLineWidth = max(maxLineWidth, lineWidth) + } + + let lastLineWidth = CGFloat(CTLineGetTypographicBounds(lines.last!, nil, nil, nil)) + + // Use framesetter for accurate total height + let suggestedSize = CTFramesetterSuggestFrameSizeWithConstraints( + framesetter, CFRange(location: 0, length: 0), nil, + CGSize(width: maxWidth, height: .greatestFiniteMagnitude), nil + ) + + return TextMeasurement( + size: CGSize(width: ceil(maxLineWidth), height: ceil(suggestedSize.height)), + trailingLineWidth: ceil(lastLineWidth) + ) + } + + // 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 } + + // 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 } + } + + return false + } +} + +// MARK: - Batch Calculation (Background Thread) + +extension MessageCellLayout { + + /// Pre-calculate layouts for all messages on background queue. + /// Telegram equivalent: ListView calls asyncLayout() on background. + static func batchCalculate( + messages: [ChatMessage], + maxBubbleWidth: CGFloat, + currentPublicKey: String, + opponentPublicKey: String, + opponentTitle: String + ) -> [String: MessageCellLayout] { + var result: [String: MessageCellLayout] = [:] + + for (index, message) in messages.enumerated() { + let isOutgoing = message.fromPublicKey == currentPublicKey + + // Calculate position + let position: BubblePosition = { + let hasPrev = index > 0 && + (messages[index - 1].fromPublicKey == currentPublicKey) == isOutgoing + let hasNext = index + 1 < messages.count && + (messages[index + 1].fromPublicKey == currentPublicKey) == isOutgoing + switch (hasPrev, hasNext) { + case (false, false): return .single + case (false, true): return .top + case (true, true): return .mid + case (true, false): return .bottom + } + }() + + // Filter garbage/encrypted text (UIKit path parity with SwiftUI MessageCellView) + let displayText = isGarbageOrEncrypted(message.text) ? "" : message.text + + // 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 hasReply = message.attachments.contains { $0.type == .messages } + let isForward = hasReply && displayText.isEmpty + + let config = Config( + maxBubbleWidth: maxBubbleWidth, + isOutgoing: isOutgoing, + position: position, + text: displayText, + hasReplyQuote: hasReply && !displayText.isEmpty, + replyName: nil, + replyText: nil, + imageCount: images.count, + fileCount: files.count, + avatarCount: avatars.count, + isForward: isForward, + forwardImageCount: isForward ? images.count : 0, + forwardFileCount: isForward ? files.count : 0, + forwardCaption: nil + ) + + result[message.id] = calculate(config: config) + } + + return result + } +} diff --git a/Rosetta/Core/Layout/MessageLayout.cpp b/Rosetta/Core/Layout/MessageLayout.cpp new file mode 100644 index 0000000..5b34219 --- /dev/null +++ b/Rosetta/Core/Layout/MessageLayout.cpp @@ -0,0 +1,115 @@ +#include "MessageLayout.hpp" +#include +#include + +namespace rosetta { + +// Constants matching MessageCellView.swift / PhotoCollageView.swift exactly +static constexpr float kReplyQuoteHeight = 41.0f; +static constexpr float kReplyQuoteTopPadding = 5.0f; +static constexpr float kFileAttachmentHeight = 56.0f; +static constexpr float kAvatarAttachmentHeight = 60.0f; +static constexpr float kTailProtrusion = 6.0f; +static constexpr float kTextPaddingVertical = 5.0f; +static constexpr float kForwardHeaderHeight = 40.0f; +static constexpr float kBorderWidth = 2.0f; +static constexpr float kCollageSpacing = 2.0f; +static constexpr float kCaptionTopPadding = 6.0f; +static constexpr float kCaptionBottomPadding = 5.0f; + +float calculateCollageHeight(int count, float width) { + if (count <= 0) return 0.0f; + + if (count == 1) { + // Single image: aspect ratio preserved, max 320pt + return std::min(width * 0.75f, 320.0f); + } + if (count == 2) { + // Side by side + float cellW = (width - kCollageSpacing) / 2.0f; + return std::min(cellW * 1.2f, 320.0f); + } + if (count == 3) { + // 1 large left + 2 stacked right + float leftW = width * 0.66f; + return std::min(leftW * 1.1f, 320.0f); + } + if (count == 4) { + // 2×2 grid + float cellW = (width - kCollageSpacing) / 2.0f; + float cellH = std::min(cellW * 0.85f, 160.0f); + return cellH * 2.0f + kCollageSpacing; + } + // 5+ images: 2 top + 3 bottom + float topH = std::min(width / 2.0f * 0.85f, 176.0f); + float botH = std::min(width / 3.0f * 0.85f, 144.0f); + return topH + kCollageSpacing + botH; +} + +MessageLayoutResult calculateLayout(const MessageLayoutInput& input) { + MessageLayoutResult result{}; + float height = 0.0f; + + // Top padding: 6pt for single/top, 2pt for mid/bottom + bool isTopOrSingle = (input.position == BubblePosition::Single || + input.position == BubblePosition::Top); + height += isTopOrSingle ? 6.0f : 2.0f; + + bool hasVisibleAttachments = (input.imageCount + input.fileCount + input.avatarCount) > 0; + + if (input.isForward) { + // ── Forwarded message ── + height += kForwardHeaderHeight; + + if (input.forwardImageCount > 0) { + result.photoCollageHeight = calculateCollageHeight( + input.forwardImageCount, input.containerWidth - 20.0f); + height += result.photoCollageHeight; + } + + height += input.forwardFileCount * kFileAttachmentHeight; + + if (input.forwardHasCaption) { + height += input.forwardCaptionHeight + kCaptionTopPadding + kCaptionBottomPadding; + } else if (input.forwardImageCount == 0 && input.forwardFileCount == 0) { + height += 20.0f; // fallback text ("Photo"/"File"/"Message") + } else { + height += 5.0f; // bottom spacer + } + } else if (hasVisibleAttachments) { + // ── Attachment bubble (images, files, avatars) ── + if (input.imageCount > 0) { + result.photoCollageHeight = calculateCollageHeight( + input.imageCount, input.containerWidth - kBorderWidth * 2.0f); + height += result.photoCollageHeight; + } + + height += input.fileCount * kFileAttachmentHeight; + height += input.avatarCount * kAvatarAttachmentHeight; + + if (input.hasText) { + height += input.textHeight + kCaptionTopPadding + kCaptionBottomPadding; + } + } else { + // ── Text-only bubble ── + if (input.hasReplyQuote) { + height += kReplyQuoteHeight + kReplyQuoteTopPadding; + } + + height += input.textHeight + kTextPaddingVertical * 2.0f; + } + + // Tail protrusion: single/bottom positions have a tail (+6pt) + bool hasTail = (input.position == BubblePosition::Single || + input.position == BubblePosition::Bottom); + if (hasTail) { + height += kTailProtrusion; + } + + result.totalHeight = std::ceil(height); + result.bubbleHeight = result.totalHeight - (isTopOrSingle ? 6.0f : 2.0f); + + return result; +} + +} // namespace rosetta diff --git a/Rosetta/Core/Layout/MessageLayout.hpp b/Rosetta/Core/Layout/MessageLayout.hpp new file mode 100644 index 0000000..c95153f --- /dev/null +++ b/Rosetta/Core/Layout/MessageLayout.hpp @@ -0,0 +1,55 @@ +#pragma once + +/// Pure C++ message cell height calculator. +/// No UIKit, no CoreText, no ObjC runtime — just math. +/// Text height is measured externally (CoreText via ObjC++ bridge) +/// and passed in as `textHeight`. + +namespace rosetta { + +enum class BubblePosition : int { + Single = 0, + Top = 1, + Mid = 2, + Bottom = 3 +}; + +enum class AttachmentType : int { + Image = 0, + Messages = 1, + File = 2, + Avatar = 3 +}; + +struct MessageLayoutInput { + float containerWidth; + float textHeight; // Measured by CoreText externally + bool hasText; + bool isOutgoing; + BubblePosition position; + bool hasReplyQuote; + bool isForward; + int imageCount; + int fileCount; + int avatarCount; + int forwardImageCount; + int forwardFileCount; + bool forwardHasCaption; + float forwardCaptionHeight; +}; + +struct MessageLayoutResult { + float totalHeight; + float bubbleHeight; + float photoCollageHeight; +}; + +/// Calculate total cell height from message properties. +/// All constants match MessageCellView.swift exactly. +MessageLayoutResult calculateLayout(const MessageLayoutInput& input); + +/// Calculate photo collage height for N images at given width. +/// Matches PhotoCollageView.swift grid formulas. +float calculateCollageHeight(int imageCount, float containerWidth); + +} // namespace rosetta diff --git a/Rosetta/Core/Layout/MessageLayoutBridge.h b/Rosetta/Core/Layout/MessageLayoutBridge.h new file mode 100644 index 0000000..d7fde9e --- /dev/null +++ b/Rosetta/Core/Layout/MessageLayoutBridge.h @@ -0,0 +1,38 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Objective-C++ bridge exposing C++ MessageLayout engine to Swift. +/// Uses CoreText for text measurement, C++ for layout math. +@interface MessageLayoutBridge : NSObject + +/// Calculate cell height for a text-only message. ++ (CGFloat)textCellHeight:(NSString *)text + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + hasReplyQuote:(BOOL)hasReplyQuote + font:(UIFont *)font; + +/// Calculate cell height for a message with direct attachments. ++ (CGFloat)attachmentCellHeightWithImages:(int)imageCount + files:(int)fileCount + avatars:(int)avatarCount + caption:(nullable NSString *)caption + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + font:(UIFont *)font; + +/// Calculate cell height for a forwarded message. ++ (CGFloat)forwardCellHeightWithImages:(int)imageCount + files:(int)fileCount + caption:(nullable NSString *)caption + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + font:(UIFont *)font; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Rosetta/Core/Layout/MessageLayoutBridge.mm b/Rosetta/Core/Layout/MessageLayoutBridge.mm new file mode 100644 index 0000000..63fb314 --- /dev/null +++ b/Rosetta/Core/Layout/MessageLayoutBridge.mm @@ -0,0 +1,112 @@ +#import "MessageLayoutBridge.h" +#include "MessageLayout.hpp" + +@implementation MessageLayoutBridge + +/// Measure text height using CoreText (10-20x faster than SwiftUI Text measurement). ++ (CGFloat)measureTextHeight:(NSString *)text + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + font:(UIFont *)font { + if (!text || text.length == 0) return 0.0; + + // Inner padding: 11pt leading, 64pt (outgoing) or 48pt (incoming) trailing + CGFloat trailingPad = isOutgoing ? 64.0 : 48.0; + CGFloat textMaxW = maxWidth - 11.0 - trailingPad; + if (textMaxW <= 0) return 0.0; + + NSDictionary *attrs = @{NSFontAttributeName: font}; + NSAttributedString *attrStr = [[NSAttributedString alloc] initWithString:text + attributes:attrs]; + CGRect rect = [attrStr boundingRectWithSize:CGSizeMake(textMaxW, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + context:nil]; + return ceil(rect.size.height); +} + ++ (CGFloat)textCellHeight:(NSString *)text + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + hasReplyQuote:(BOOL)hasReplyQuote + font:(UIFont *)font { + CGFloat textH = [self measureTextHeight:text maxWidth:maxWidth isOutgoing:isOutgoing font:font]; + + rosetta::MessageLayoutInput input{}; + input.containerWidth = static_cast(maxWidth); + input.textHeight = static_cast(textH); + input.hasText = (text.length > 0); + input.isOutgoing = isOutgoing; + input.position = static_cast(position); + input.hasReplyQuote = hasReplyQuote; + input.isForward = false; + input.imageCount = 0; + input.fileCount = 0; + input.avatarCount = 0; + + auto result = rosetta::calculateLayout(input); + return static_cast(result.totalHeight); +} + ++ (CGFloat)attachmentCellHeightWithImages:(int)imageCount + files:(int)fileCount + avatars:(int)avatarCount + caption:(nullable NSString *)caption + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + font:(UIFont *)font { + CGFloat captionH = 0; + if (caption && caption.length > 0) { + captionH = [self measureTextHeight:caption maxWidth:maxWidth isOutgoing:isOutgoing font:font]; + } + + rosetta::MessageLayoutInput input{}; + input.containerWidth = static_cast(maxWidth); + input.textHeight = static_cast(captionH); + input.hasText = (caption && caption.length > 0); + input.isOutgoing = isOutgoing; + input.position = static_cast(position); + input.hasReplyQuote = false; + input.isForward = false; + input.imageCount = imageCount; + input.fileCount = fileCount; + input.avatarCount = avatarCount; + + auto result = rosetta::calculateLayout(input); + return static_cast(result.totalHeight); +} + ++ (CGFloat)forwardCellHeightWithImages:(int)imageCount + files:(int)fileCount + caption:(nullable NSString *)caption + maxWidth:(CGFloat)maxWidth + isOutgoing:(BOOL)isOutgoing + position:(int)position + font:(UIFont *)font { + CGFloat captionH = 0; + if (caption && caption.length > 0) { + captionH = [self measureTextHeight:caption maxWidth:maxWidth isOutgoing:isOutgoing font:font]; + } + + rosetta::MessageLayoutInput input{}; + input.containerWidth = static_cast(maxWidth); + input.textHeight = 0; + input.hasText = false; + input.isOutgoing = isOutgoing; + input.position = static_cast(position); + input.hasReplyQuote = false; + input.isForward = true; + input.imageCount = 0; + input.fileCount = 0; + input.avatarCount = 0; + input.forwardImageCount = imageCount; + input.forwardFileCount = fileCount; + input.forwardHasCaption = (caption && caption.length > 0); + input.forwardCaptionHeight = static_cast(captionH); + + auto result = rosetta::calculateLayout(input); + return static_cast(result.totalHeight); +} + +@end diff --git a/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift b/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift deleted file mode 100644 index a7fdf00..0000000 --- a/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift +++ /dev/null @@ -1,218 +0,0 @@ -import SwiftUI -import UIKit - -/// Telegram-style keyboard synchronization: list and composer are TWO independent -/// UIHostingControllers within a single UIViewController. -/// -/// - Composer is pinned to `keyboardLayoutGuide.topAnchor` — UIKit physically moves -/// its position Y when keyboard appears (no SwiftUI relayout). -/// - List extends to the same bottom as composer (under it) for glass/blur effect. -/// - Inverted ScrollView inside list keeps messages glued to the input bar. -/// - Interactive dismiss follows automatically via keyboardLayoutGuide. -/// - Composer height reported from UIKit (`view.bounds.height`) — automatically -/// includes safe area when keyboard hidden, excludes when keyboard open. -/// - Nav bar height reported via `onTopSafeAreaChange` — SwiftUI uses it for -/// `.safeAreaInset(edge: .bottom)` (= visual top in inverted scroll). -/// -/// On iOS 26+, SwiftUI handles keyboard natively — passthrough for content only. -struct KeyboardSyncedContainer: View { - let content: Content - let composer: Composer - var onComposerHeightChange: ((CGFloat) -> Void)? - var onTopSafeAreaChange: ((CGFloat) -> Void)? - - init( - @ViewBuilder content: () -> Content, - @ViewBuilder composer: () -> Composer, - onComposerHeightChange: ((CGFloat) -> Void)? = nil, - onTopSafeAreaChange: ((CGFloat) -> Void)? = nil - ) { - self.content = content() - self.composer = composer() - self.onComposerHeightChange = onComposerHeightChange - self.onTopSafeAreaChange = onTopSafeAreaChange - } - - var body: some View { - if #available(iOS 26, *) { - // iOS 26+: caller handles composer via overlay. Container is passthrough. - content - } else { - _KeyboardSyncedRepresentable( - content: content, - composer: composer, - onComposerHeightChange: onComposerHeightChange, - onTopSafeAreaChange: onTopSafeAreaChange - ) - .ignoresSafeArea() - } - } -} - -// MARK: - UIViewControllerRepresentable bridge - -private struct _KeyboardSyncedRepresentable< - Content: View, - Composer: View ->: UIViewControllerRepresentable { - let content: Content - let composer: Composer - var onComposerHeightChange: ((CGFloat) -> Void)? - var onTopSafeAreaChange: ((CGFloat) -> Void)? - - func makeUIViewController( - context: Context - ) -> _KeyboardSyncedVC { - let vc = _KeyboardSyncedVC(content: content, composer: composer) - vc.onComposerHeightChange = onComposerHeightChange - vc.onTopSafeAreaChange = onTopSafeAreaChange - return vc - } - - func updateUIViewController( - _ vc: _KeyboardSyncedVC, - context: Context - ) { - vc.listController.rootView = content - vc.composerController.rootView = composer - vc.onComposerHeightChange = onComposerHeightChange - vc.onTopSafeAreaChange = onTopSafeAreaChange - } - - // No sizeThatFits — container fills all proposed space from parent. -} - -// MARK: - UIKit view controller: two hosting controllers - -final class _KeyboardSyncedVC: UIViewController, UIGestureRecognizerDelegate { - - let listController: UIHostingController - let composerController: UIHostingController - - var onComposerHeightChange: ((CGFloat) -> Void)? - var onTopSafeAreaChange: ((CGFloat) -> Void)? - private var lastReportedComposerHeight: CGFloat = 0 - private var lastReportedTopSafeArea: CGFloat = 0 - - init(content: Content, composer: Composer) { - listController = UIHostingController(rootView: content) - composerController = UIHostingController(rootView: composer) - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError() } - - deinit { - // Explicit deinit — workaround for Swift compiler crash in Release - // optimization (SILFunctionTransform "EarlyPerfInliner" on deinit). - onComposerHeightChange = nil - onTopSafeAreaChange = nil - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - // Configure list hosting controller — NO safe area (clean rectangle). - // UIKit transform (y: -1) inverts safe areas which breaks UIScrollView math. - // Nav bar inset is bridged to SwiftUI via onTopSafeAreaChange callback. - listController.view.backgroundColor = .clear - if #available(iOS 16.4, *) { - listController.safeAreaRegions = [] - } - - // Configure composer hosting controller - composerController.view.backgroundColor = .clear - if #available(iOS 16.4, *) { - composerController.safeAreaRegions = .container - } - if #available(iOS 16.0, *) { - composerController.sizingOptions = .intrinsicContentSize - } - composerController.view.setContentHuggingPriority(.required, for: .vertical) - composerController.view.setContentCompressionResistancePriority( - .required, for: .vertical - ) - - // Add children — composer on top of list (z-order) - addChild(listController) - addChild(composerController) - view.addSubview(listController.view) - view.addSubview(composerController.view) - - listController.view.translatesAutoresizingMaskIntoConstraints = false - composerController.view.translatesAutoresizingMaskIntoConstraints = false - - // Telegram-style inversion: flip the list UIView, NOT the SwiftUI ScrollView. - listController.view.transform = CGAffineTransform(scaleX: 1, y: -1) - - // When keyboard is hidden, guide top = view bottom (not safe area bottom). - view.keyboardLayoutGuide.usesBottomSafeArea = false - - NSLayoutConstraint.activate([ - composerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - composerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - composerController.view.bottomAnchor.constraint( - equalTo: view.keyboardLayoutGuide.topAnchor - ), - - // Fixed height = screen height. List slides up as a unit when keyboard opens. - listController.view.heightAnchor.constraint(equalTo: view.heightAnchor), - listController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - listController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - listController.view.bottomAnchor.constraint( - equalTo: view.keyboardLayoutGuide.topAnchor - ), - ]) - - listController.didMove(toParent: self) - composerController.didMove(toParent: self) - - // Swipe down on composer to dismiss keyboard. - let panGesture = UIPanGestureRecognizer( - target: self, action: #selector(handleComposerPan(_:)) - ) - panGesture.delegate = self - composerController.view.addGestureRecognizer(panGesture) - } - - @objc private func handleComposerPan(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: composerController.view) - let velocity = gesture.velocity(in: composerController.view) - if translation.y > 10 && velocity.y > 100 { - view.endEditing(true) - } - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - true - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - // Bridge: UIKit measures exact nav bar height → SwiftUI applies via .safeAreaInset. - // No additionalSafeAreaInsets (negative values break UIScrollView math). - let navBarHeight = view.safeAreaInsets.top - if abs(navBarHeight - lastReportedTopSafeArea) > 1 { - lastReportedTopSafeArea = navBarHeight - DispatchQueue.main.async { [weak self] in - self?.onTopSafeAreaChange?(navBarHeight) - } - } - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - let height = composerController.view.bounds.height - if height > 0, abs(height - lastReportedComposerHeight) > 1 { - lastReportedComposerHeight = height - DispatchQueue.main.async { [weak self] in - self?.onComposerHeightChange?(height) - } - } - } -} diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index d958d30..3ec11dd 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -10,20 +10,6 @@ private struct ComposerHeightKey: PreferenceKey { } } -/// Reserves space at the bottom of the scroll content for the composer. -/// Both iOS versions: list extends under the composer (overlay pattern), -/// spacer prevents messages from going behind the composer. -/// iOS < 26: composerHeight is measured from UIKit view.bounds.height -/// (includes safe area when keyboard hidden, excludes when open). -private struct KeyboardSpacer: View { - let composerHeight: CGFloat - - var body: some View { - Color.clear.frame(height: max(composerHeight, 0)) - } -} - - struct ChatDetailView: View { let route: ChatRoute var onPresentedChange: ((Bool) -> Void)? = nil @@ -46,9 +32,6 @@ struct ChatDetailView: View { @State private var isInputFocused = false @State private var isAtBottom = true @State private var composerHeight: CGFloat = 56 - /// Nav bar height from UIKit (Bridge pattern). Used as padding inside scroll - /// content to prevent messages from going behind nav bar on iOS < 26. - @State private var topSafeArea: CGFloat = 0 @State private var shouldScrollOnNextMessage = false /// Captured on chat open — ID of the first unread incoming message (for separator). @State private var firstUnreadMessageId: String? @@ -67,6 +50,8 @@ struct ChatDetailView: View { @State private var scrollToMessageId: String? /// ID of message currently highlighted after scroll-to-reply navigation. @State private var highlightedMessageId: String? + /// Triggers NativeMessageList to scroll to bottom (button tap). + @State private var scrollToBottomRequested = false /// Stable callback reference for message cell interactions. /// Class ref pointer is stable across parent re-renders → cells not marked dirty. @@ -141,45 +126,12 @@ struct ChatDetailView: View { .spring(response: 0.28, dampingFraction: 0.9) } - private var messagesTopInset: CGFloat { 6 } - - /// Nav bar padding for inverted scroll (iOS < 26). - /// UIKit transform flips safe areas — this adds correct top padding inside scroll content. - /// On iOS 26+: 0 (SwiftUI handles safe areas natively). - private var navBarPadding: CGFloat { - if #available(iOS 26, *) { return 0 } - return topSafeArea - } - - /// Scroll-to-bottom button padding above bottom edge (above composer). - private var scrollToBottomPadding: CGFloat { - composerHeight + 4 - } - - /// Scroll-to-bottom button alignment within scroll overlay. - /// iOS < 26: UIKit transform flips the view — .top becomes visual bottom. - private var scrollToBottomAlignment: Alignment { - if #available(iOS 26, *) { return .bottom } - return .top - } - - /// Padding edge for scroll-to-bottom button. - /// iOS < 26: .top = visual bottom after UIKit flip. - private var scrollToBottomPaddingEdge: Edge.Set { - if #available(iOS 26, *) { return .bottom } - return .top - } - - private static let scrollBottomAnchorId = "chat_detail_bottom_anchor" - private var maxBubbleWidth: CGFloat { max(min(UIScreen.main.bounds.width * 0.72, 380), 140) } /// Visual chat content: messages list + gradient overlays + background. - /// NO composer overlay — on iOS < 26 composer is a separate UIHostingController. - /// On iOS < 26 the entire listController.view is UIKit-flipped (transform y: -1), - /// so gradients/backgrounds use CounterUIKitFlipModifier to stay screen-relative. + /// No parent UIKit flip — NativeMessageListController manages its own collectionView transform. @ViewBuilder private var chatArea: some View { ZStack { @@ -187,7 +139,6 @@ struct ChatDetailView: View { } .overlay { chatEdgeGradients - .modifier(CounterUIKitFlipModifier()) } // FPS overlay — uncomment for performance testing: // .overlay { FPSOverlayView() } @@ -195,7 +146,6 @@ struct ChatDetailView: View { ZStack { RosettaColors.Adaptive.background tiledChatBackground - .modifier(CounterUIKitFlipModifier()) } .ignoresSafeArea() } @@ -204,11 +154,9 @@ struct ChatDetailView: View { @ViewBuilder private var content: some View { let _ = PerformanceLogger.shared.track("chatDetail.bodyEval") - // iOS < 26: KeyboardSyncedContainer uses TWO UIHostingControllers. - // Composer pinned to keyboardLayoutGuide (UIKit moves position Y). - // List bottom pinned to composer top (shrinks when composer moves up). - // Zero SwiftUI relayout jump — Telegram-style sync. - // iOS 26+: SwiftUI handles keyboard natively — overlay approach. + // iOS 26+: SwiftUI handles keyboard natively — ComposerOverlay. + // iOS < 26: Composer embedded in NativeMessageListController via UIHostingController + // pinned to keyboardLayoutGuide — frame-perfect keyboard sync (Telegram-style). Group { if #available(iOS 26, *) { chatArea @@ -224,18 +172,14 @@ struct ChatDetailView: View { composerHeight = newHeight } } else { - KeyboardSyncedContainer( - content: { chatArea }, - composer: { - if !route.isSystemAccount { composer } - }, - onComposerHeightChange: { height in - composerHeight = height - }, - onTopSafeAreaChange: { inset in - topSafeArea = inset - } - ) + // iOS < 26: composer is inside NativeMessageListController. + // UIKit handles ALL keyboard/safe area insets manually via + // contentInsetAdjustmentBehavior = .never + applyInsets(). + // Tell SwiftUI to not adjust frame for ANY safe area edge — + // this ensures keyboardWillChangeFrameNotification reaches + // the embedded controller without interference. + chatArea + .ignoresSafeArea() } } .navigationBarTitleDisplayMode(.inline) @@ -756,189 +700,76 @@ private extension ChatDetailView { @ViewBuilder private func messagesScrollView(maxBubbleWidth: CGFloat) -> some View { - ScrollViewReader { proxy in - let scroll = ScrollView(.vertical, showsIndicators: false) { - VStack(spacing: 0) { - // Anchor at VStack START → after flip = visual BOTTOM (newest edge). - // scrollTo(anchor, .top) places this at viewport top = visual bottom. - Color.clear - .frame(height: 4) - .id(Self.scrollBottomAnchorId) + let useComposer: Bool = { + if #available(iOS 26, *) { return false } + return !route.isSystemAccount + }() - // Spacer for composer + keyboard — OUTSIDE LazyVStack. - // In inverted scroll, spacer at START pushes messages away from - // offset=0. When spacer grows (keyboard opens), messages move up - // visually — no scrollTo needed, no defaultScrollAnchor needed. - KeyboardSpacer(composerHeight: composerHeight) + // Reply info for ComposerView + let replySender: String? = replyingToMessage.map { senderDisplayName(for: $0.fromPublicKey) } + let replyPreview: String? = replyingToMessage.map { replyPreviewText(for: $0) } - // LazyVStack: only visible cells are loaded. - LazyVStack(spacing: 0) { - // Sentinel for viewport-based scroll tracking. - // Must be inside LazyVStack — regular VStack doesn't - // fire onAppear/onDisappear on scroll. - Color.clear - .frame(height: 1) - .onAppear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = true } } - .onDisappear { withAnimation(.easeOut(duration: 0.2)) { isAtBottom = false } } - - // PERF: VStack wrapper ensures each ForEach element produces - // exactly 1 view → SwiftUI uses FAST PATH (O(1) diffing). - // Without it: conditional unreadSeparator makes element count - // variable → SLOW PATH (O(n) full scan on every update). - ForEach(messages.reversed()) { message in - VStack(spacing: 0) { - let index = messageIndex(for: message.id) - let position = bubblePosition(for: index) - MessageCellView( - message: message, - maxBubbleWidth: maxBubbleWidth, - position: position, - currentPublicKey: currentPublicKey, - highlightedMessageId: highlightedMessageId, - isSavedMessages: route.isSavedMessages, - isSystemAccount: route.isSystemAccount, - opponentPublicKey: route.publicKey, - opponentTitle: route.title, - opponentUsername: route.username, - actions: cellActions - ) - .equatable() - .scaleEffect(x: 1, y: -1) // flip each row back to normal - - // Unread Messages separator (Telegram style). - if message.id == firstUnreadMessageId { - unreadSeparator - .scaleEffect(x: 1, y: -1) - } - } - } - - // PAGINATION TRIGGER (end of LazyVStack = visual top of chat). - // When sentinel appears, load older messages from SQLite. - if viewModel.hasMoreMessages { - ProgressView() - .frame(height: 40) - .frame(maxWidth: .infinity) - .scaleEffect(x: 1, y: -1) - .onAppear { - Task { await viewModel.loadMore() } - } - } - } - } - .padding(.horizontal, 10) - // visual top (near nav bar): messagesTopInset + navBarPadding. - // navBarPadding = topSafeArea on iOS < 26 (UIKit flip inverts safe areas). - .padding(.bottom, messagesTopInset + navBarPadding) - } - // iOS 26: disable default scroll edge blur — in inverted scroll the top+bottom - // effects overlap and blur the entire screen. - .modifier(DisableScrollEdgeEffectModifier()) - // iOS 26+: SwiftUI scaleEffect for inversion. - // iOS < 26: UIKit transform on listController.view handles inversion — - // no SwiftUI scaleEffect needed (avoids center-shift jump on frame resize). - .modifier(ScrollInversionModifier()) - .scrollDismissesKeyboard(.interactively) - .onTapGesture { isInputFocused = false } - .onAppear { - // In inverted scroll, offset 0 IS the visual bottom — no scroll needed. - // Safety scroll for edge cases (e.g., view recycling). - DispatchQueue.main.async { scrollToBottom(proxy: proxy, animated: false) } - } - .onChange(of: messages.last?.id) { _, _ in + NativeMessageListView( + messages: messages, + maxBubbleWidth: maxBubbleWidth, + currentPublicKey: currentPublicKey, + highlightedMessageId: highlightedMessageId, + route: route, + actions: cellActions, + hasMoreMessages: viewModel.hasMoreMessages, + firstUnreadMessageId: firstUnreadMessageId, + useUIKitComposer: useComposer, + scrollToMessageId: scrollToMessageId, + shouldScrollToBottom: shouldScrollOnNextMessage, + scrollToBottomRequested: $scrollToBottomRequested, + onAtBottomChange: { atBottom in + isAtBottom = atBottom + }, + onPaginate: { + Task { await viewModel.loadMore() } + }, + onTapBackground: { + isInputFocused = false + }, + onNewMessageAutoScroll: { + shouldScrollOnNextMessage = false let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true - if shouldScrollOnNextMessage || lastIsOutgoing || isAtBottom { - DispatchQueue.main.async { - scrollToBottom(proxy: proxy, animated: true) - } - shouldScrollOnNextMessage = false - } if isViewActive && !lastIsOutgoing && !route.isSavedMessages && !route.isSystemAccount { markDialogAsRead() } - } - // Scroll-to-reply: navigate to the original message and highlight it briefly. - .onChange(of: scrollToMessageId) { _, targetId in - guard let targetId else { return } + }, + onComposerHeightChange: { composerHeight = $0 }, + onKeyboardDidHide: { isInputFocused = false }, + messageText: $messageText, + isInputFocused: $isInputFocused, + replySenderName: replySender, + replyPreviewText: replyPreview, + onSend: sendCurrentMessage, + onAttach: { showAttachmentPanel = true }, + onTyping: handleComposerUserTyping, + onReplyCancel: { withAnimation(.easeOut(duration: 0.15)) { replyingToMessage = nil } } + ) + .onChange(of: scrollToMessageId) { _, targetId in + guard let targetId else { return } + // NativeMessageListView handles the actual scroll via scrollToMessageId param. + // Here we only manage the highlight animation. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { scrollToMessageId = nil - withAnimation(.easeInOut(duration: 0.3)) { - proxy.scrollTo(targetId, anchor: .center) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { - withAnimation(.easeIn(duration: 0.2)) { - highlightedMessageId = targetId - } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { - withAnimation(.easeOut(duration: 0.5)) { - highlightedMessageId = nil - } - } + withAnimation(.easeIn(duration: 0.2)) { highlightedMessageId = targetId } + DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + withAnimation(.easeOut(duration: 0.5)) { highlightedMessageId = nil } } } - // No keyboard scroll handlers needed — inverted scroll keeps bottom anchored. - scroll - .scrollIndicators(.hidden) - .overlay(alignment: scrollToBottomAlignment) { - scrollToBottomButton(proxy: proxy) - .modifier(CounterUIKitFlipModifier()) - .padding(scrollToBottomPaddingEdge, scrollToBottomPadding) - } } } - @ViewBuilder - private func scrollToBottomButton(proxy: ScrollViewProxy) -> some View { - // Positioning container — always present, no transition on it. - // Only the button itself animates in/out. - HStack { - Spacer() - if !isAtBottom { - Button { - scrollToBottom(proxy: proxy, animated: true) - } label: { - TelegramVectorIcon( - pathData: TelegramIconPath.chevronDown, - viewBox: CGSize(width: 22, height: 12), - color: .white - ) - .frame(width: 14, height: 8) - .frame(width: 42, height: 42) - .contentShape(Circle()) - .background { - glass(shape: .circle, strokeOpacity: 0.18) - } - } - .buttonStyle(ChatDetailGlassPressButtonStyle()) - .transition(.scale(scale: 0.01, anchor: .center).combined(with: .opacity)) - } - } - .padding(.trailing, composerTrailingPadding) - .allowsHitTesting(!isAtBottom) - } + // Scroll-to-bottom button moved to UIKit (NativeMessageListController) + // — pinned to composer via Auto Layout constraint for pixel-perfect sync. // Message row rendering extracted to MessageCellView (Equatable, .equatable() modifier). - // Remaining methods: messageRow, textOnlyBubble, attachmentBubble, forwardedMessageBubble, - // timestampOverlay, mediaTimestampOverlay, bubbleBackground, deliveryIndicator, errorMenu, - // replyQuoteView, parsedMarkdown, messageTime, parseReplyBlob, senderDisplayName, - // cachedBlurHash, contextMenuReadStatus, bubbleActions, collageAttachmentId, all static caches. // See MessageCellView.swift. - - // MARK: - Unread Separator - - private var unreadSeparator: some View { - Text("Unread Messages") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.white.opacity(0.7)) - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.white.opacity(0.08)) - .padding(.horizontal, -10) // compensate scroll content padding - .padding(.top, 6) - .padding(.bottom, 2) - } - // MARK: - Composer var composer: some View { @@ -985,7 +816,13 @@ private extension ChatDetailView { text: $messageText, isFocused: $isInputFocused, onKeyboardHeightChange: { height in - KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) + // Phase 7: Route KVO to NativeMessageListController via NotificationCenter + // for interactive keyboard dismiss (finger tracking) + NotificationCenter.default.post( + name: NSNotification.Name("InteractiveKeyboardHeightChanged"), + object: nil, + userInfo: ["height": height] + ) }, onUserTextInsertion: handleComposerUserTyping, onMultilineChange: { multiline in @@ -1096,57 +933,6 @@ private extension ChatDetailView { } } - // MARK: - Message Index Lookup - - /// PERF: O(1) index lookup via cached dictionary. Rebuilt lazily when messages change. - /// Avoids O(n) `firstIndex(where:)` per cell in reversed ForEach. - @MainActor private static var messageIndexCache: [String: Int] = [:] - @MainActor private static var messageIndexCacheKey: String = "" - - private func messageIndex(for messageId: String) -> Int { - // Rebuild cache if messages array changed (first+last+count fingerprint). - let cacheKey = "\(messages.count)_\(messages.first?.id ?? "")_\(messages.last?.id ?? "")" - if Self.messageIndexCacheKey != cacheKey { - Self.messageIndexCache.removeAll(keepingCapacity: true) - for (i, msg) in messages.enumerated() { - Self.messageIndexCache[msg.id] = i - } - Self.messageIndexCacheKey = cacheKey - } - return Self.messageIndexCache[messageId] ?? 0 - } - - // MARK: - Bubble Position (Figma: Single / Top / Mid / Bottom) - - /// Determines bubble position within a group of consecutive same-sender messages. - /// Telegram parity: photo messages group with text messages from the same sender. - func bubblePosition(for index: Int) -> BubblePosition { - let hasPrev: Bool = { - guard index > 0 else { return false } - let prev = messages[index - 1] - let current = messages[index] - let sameSender = current.isFromMe(myPublicKey: currentPublicKey) - == prev.isFromMe(myPublicKey: currentPublicKey) - return sameSender - }() - - let hasNext: Bool = { - guard index + 1 < messages.count else { return false } - let next = messages[index + 1] - let current = messages[index] - let sameSender = current.isFromMe(myPublicKey: currentPublicKey) - == next.isFromMe(myPublicKey: currentPublicKey) - return sameSender - }() - - switch (hasPrev, hasNext) { - case (false, false): return .single - case (false, true): return .top - case (true, true): return .mid - case (true, false): return .bottom - } - } - // MARK: - Glass enum ChatGlassShape { @@ -1292,6 +1078,27 @@ private extension ChatDetailView { // MARK: - Reply Bar + /// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar). + func replyPreviewText(for message: ChatMessage) -> String { + if message.attachments.contains(where: { $0.type == .image }) { + let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + return caption.isEmpty ? "Photo" : caption + } + if let file = message.attachments.first(where: { $0.type == .file }) { + let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !caption.isEmpty { return caption } + let parts = file.preview.components(separatedBy: "::") + if parts.count >= 3 { return parts[2] } + return file.id.isEmpty ? "File" : file.id + } + if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" } + if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" } + let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return message.text } + if !message.attachments.isEmpty { return "Attachment" } + return "" + } + @ViewBuilder func replyBar(for message: ChatMessage) -> some View { let senderName = senderDisplayName(for: message.fromPublicKey) @@ -1685,19 +1492,6 @@ private extension ChatDetailView { } - func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { - // Inverted scroll: .top anchor in scroll coordinates = visual bottom on screen. - if animated { - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top) - } - } else { - proxy.scrollTo(Self.scrollBottomAnchorId, anchor: .top) - } - } - - // isTailVisible replaced by bubblePosition(for:) above - func requestUserInfoIfNeeded() { // Always request — we need fresh online status even if title is already populated. SessionManager.shared.requestUserInfoIfNeeded(forKey: route.publicKey) @@ -1952,49 +1746,6 @@ private struct ComposerOverlay: View { } } -/// iOS 26: scroll edge blur is on by default — in inverted scroll (scaleEffect y: -1) -/// both top+bottom edge effects overlap and blur the entire screen. -/// Hide only the ScrollView's top edge (= visual bottom after inversion, near composer). -/// Keep ScrollView's bottom edge (= visual top after inversion, near nav bar) for a -/// nice fade effect when scrolling through older messages. -private struct DisableScrollEdgeEffectModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - content.scrollEdgeEffectHidden(true, for: .top) - } else { - content - } - } -} - -/// iOS < 26: UIKit transform on listController.view handles scroll inversion. -/// iOS 26+: SwiftUI scaleEffect (no UIKit container, native keyboard handling). - - -private struct ScrollInversionModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - content.scaleEffect(x: 1, y: -1) - } else { - content // UIKit CGAffineTransform(scaleX: 1, y: -1) on listController.view - } - } -} - -/// Counteracts the UIKit y-flip on listController.view for iOS < 26. -/// Elements that should appear screen-relative (gradients, backgrounds, -/// buttons) use this to flip back to normal orientation. -/// iOS 26+: no UIKit flip, no counter needed — passthrough. -private struct CounterUIKitFlipModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - } else { - content.scaleEffect(x: 1, y: -1) - } - } -} - // MARK: - ForwardedPhotoCollageView /// Telegram-style collage layout for forwarded image attachments (same patterns as PhotoCollageView). diff --git a/Rosetta/Features/Chats/ChatDetail/ComposerView.swift b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift new file mode 100644 index 0000000..ef7f93b --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/ComposerView.swift @@ -0,0 +1,549 @@ +import UIKit + +// MARK: - ComposerViewDelegate + +@MainActor +protocol ComposerViewDelegate: AnyObject { + func composerDidTapSend(_ composer: ComposerView) + func composerDidTapAttach(_ composer: ComposerView) + func composerTextDidChange(_ composer: ComposerView, text: String) + func composerFocusDidChange(_ composer: ComposerView, isFocused: Bool) + func composerHeightDidChange(_ composer: ComposerView, height: CGFloat) + func composerDidCancelReply(_ composer: ComposerView) + func composerUserDidType(_ composer: ComposerView) + func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat) +} + +// MARK: - ComposerView + +/// Pure UIKit composer — replaces the SwiftUI UIHostingController. +/// Eliminates UIHostingController sizing bugs during keyboard transitions. +/// Frame-based layout (Telegram pattern), no Auto Layout inside. +final class ComposerView: UIView, UITextViewDelegate { + + weak var delegate: ComposerViewDelegate? + + // MARK: - Public State + + var currentText: String { textView.text ?? "" } + + private(set) var currentHeight: CGFloat = 0 + + // MARK: - Subviews + + // Attach button (glass circle, 42×42) + private let attachButton = UIButton(type: .system) + private let attachGlass = TelegramGlassUIView(frame: .zero) + + // Input container (glass rounded rect) + private let inputContainer = UIView() + private let inputGlass = TelegramGlassUIView(frame: .zero) + + // Reply bar + private let replyBar = UIView() + private let replyBlueBar = UIView() + private let replyTitleLabel = UILabel() + private let replySenderLabel = UILabel() + private let replyPreviewLabel = UILabel() + private let replyCancelButton = UIButton(type: .system) + private var isReplyVisible = false + + // Text input (reuses ChatInputTextView from ChatTextInput.swift) + private let textView = ChatInputTextView() + + // Emoji button + private let emojiButton = UIButton(type: .system) + + // Send button (blue capsule, 38×36) + private let sendButton = UIButton(type: .system) + private let sendCapsule = UIView() + + // Mic button (glass circle, 42×42) + private let micButton = UIButton(type: .system) + private let micGlass = TelegramGlassUIView(frame: .zero) + + // MARK: - Layout Constants + + private let horizontalPadding: CGFloat = 16 + private let buttonSize: CGFloat = 42 + private let innerSpacing: CGFloat = 6 + private let innerPadding: CGFloat = 3 + private let minInputContainerHeight: CGFloat = 42 + private let sendButtonWidth: CGFloat = 38 + private let sendButtonHeight: CGFloat = 36 + private let textViewMinHeight: CGFloat = 36 + private let maxLines: CGFloat = 5 + private let topPadding: CGFloat = 6 + private let bottomPadding: CGFloat = 12 + + // MARK: - State + + private var textViewHeight: CGFloat = 36 + private var isMultiline = false + private var isSendVisible = false + private var isUpdatingText = false + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .clear + setupSubviews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Setup + + private func setupSubviews() { + // --- Attach button --- + attachGlass.isCircle = true + attachGlass.isUserInteractionEnabled = false + attachButton.addSubview(attachGlass) + let attachIcon = makeIconLayer( + pathData: TelegramIconPath.paperclip, + viewBox: CGSize(width: 21, height: 24), + targetSize: CGSize(width: 21, height: 24), + color: .white + ) + attachButton.layer.addSublayer(attachIcon) + attachButton.tag = 1 // for icon centering in layoutSubviews + attachButton.addTarget(self, action: #selector(attachTapped), for: .touchUpInside) + addSubview(attachButton) + + // --- Input container --- + inputContainer.backgroundColor = .clear + inputContainer.clipsToBounds = true + inputGlass.fixedCornerRadius = 21 + inputGlass.isUserInteractionEnabled = false + inputContainer.addSubview(inputGlass) + addSubview(inputContainer) + + // --- Reply bar (hidden by default) --- + replyBar.alpha = 0 + replyBar.isHidden = true + + replyBlueBar.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) + replyBlueBar.layer.cornerRadius = 1.5 + replyBar.addSubview(replyBlueBar) + + replyTitleLabel.font = .systemFont(ofSize: 14, weight: .medium) + replyTitleLabel.textColor = UIColor(white: 1, alpha: 0.6) + replyTitleLabel.text = "Reply to " + replyBar.addSubview(replyTitleLabel) + + replySenderLabel.font = .systemFont(ofSize: 14, weight: .semibold) + replySenderLabel.textColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) + replyBar.addSubview(replySenderLabel) + + replyPreviewLabel.font = .systemFont(ofSize: 14, weight: .regular) + replyPreviewLabel.textColor = .white + replyPreviewLabel.lineBreakMode = .byTruncatingTail + replyBar.addSubview(replyPreviewLabel) + + let xImage = UIImage(systemName: "xmark", withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)) + replyCancelButton.setImage(xImage, for: .normal) + replyCancelButton.tintColor = UIColor(white: 1, alpha: 0.6) + replyCancelButton.addTarget(self, action: #selector(replyCancelTapped), for: .touchUpInside) + replyBar.addSubview(replyCancelButton) + + inputContainer.addSubview(replyBar) + + // --- Text view --- + textView.delegate = self + textView.font = .systemFont(ofSize: 17, weight: .regular) + textView.textColor = .white + textView.backgroundColor = .clear + textView.tintColor = UIColor(red: 36/255.0, green: 138/255.0, blue: 230/255.0, alpha: 1) + textView.isScrollEnabled = false + textView.textContainerInset = UIEdgeInsets(top: 7, left: 2, bottom: 7, right: 0) + textView.textContainer.lineFragmentPadding = 0 + textView.autocapitalizationType = .sentences + textView.autocorrectionType = .default + textView.keyboardAppearance = .dark + textView.returnKeyType = .default + + textView.placeholderLabel.text = "Message" + textView.placeholderLabel.font = .systemFont(ofSize: 17, weight: .regular) + textView.placeholderLabel.textColor = UIColor.white.withAlphaComponent(0.35) + + textView.trackingView.onHeightChange = { [weak self] height in + guard let self else { return } + self.delegate?.composerKeyboardHeightDidChange(self, height: height) + } + + inputContainer.addSubview(textView) + + // --- Emoji button --- + let emojiIcon = makeIconLayer( + pathData: TelegramIconPath.emojiMoon, + viewBox: CGSize(width: 19, height: 19), + targetSize: CGSize(width: 19, height: 19), + color: UIColor(white: 1, alpha: 0.6) + ) + emojiButton.layer.addSublayer(emojiIcon) + emojiButton.tag = 2 + emojiButton.addTarget(self, action: #selector(emojiTapped), for: .touchUpInside) + inputContainer.addSubview(emojiButton) + + // --- Send button --- + sendCapsule.backgroundColor = UIColor(red: 0, green: 0x8B/255.0, blue: 1.0, alpha: 1) + sendCapsule.isUserInteractionEnabled = false + sendButton.addSubview(sendCapsule) + + let sendIcon = makeIconLayer( + pathData: TelegramIconPath.sendPlane, + viewBox: CGSize(width: 22, height: 19), + targetSize: CGSize(width: 22, height: 19), + color: .white + ) + sendButton.layer.addSublayer(sendIcon) + sendButton.tag = 3 + sendButton.addTarget(self, action: #selector(sendTapped), for: .touchUpInside) + sendButton.alpha = 0 + sendButton.transform = CGAffineTransform(scaleX: 0.74, y: 0.74) + inputContainer.addSubview(sendButton) + + // --- Mic button --- + micGlass.isCircle = true + micGlass.isUserInteractionEnabled = false + micButton.addSubview(micGlass) + let micIcon = makeIconLayer( + pathData: TelegramIconPath.microphone, + viewBox: CGSize(width: 18, height: 24), + targetSize: CGSize(width: 18, height: 24), + color: .white + ) + micButton.layer.addSublayer(micIcon) + micButton.tag = 4 + micButton.addTarget(self, action: #selector(micTapped), for: .touchUpInside) + addSubview(micButton) + } + + // MARK: - Public API + + func setText(_ text: String) { + guard text != textView.text else { return } + isUpdatingText = true + textView.text = text + textView.placeholderLabel.isHidden = !text.isEmpty + isUpdatingText = false + recalculateTextHeight() + updateSendMicVisibility(animated: false) + } + + func setReply(senderName: String?, previewText: String?) { + let shouldShow = senderName != nil + guard shouldShow != isReplyVisible else { return } + isReplyVisible = shouldShow + + if shouldShow { + replySenderLabel.text = senderName + replyPreviewLabel.text = previewText ?? "" + replyBar.isHidden = false + } + + UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) { + self.replyBar.alpha = shouldShow ? 1 : 0 + self.replyBar.transform = shouldShow ? .identity : CGAffineTransform(translationX: 0, y: 20) + } completion: { _ in + if !shouldShow { + self.replyBar.isHidden = true + self.replyBar.transform = .identity + } + } + + setNeedsLayout() + // Report height change after layout + DispatchQueue.main.async { [weak self] in + self?.layoutIfNeeded() + self?.reportHeightIfChanged() + } + } + + func setFocused(_ focused: Bool) { + if focused, !textView.isFirstResponder { + DispatchQueue.main.async { [weak self] in + self?.textView.becomeFirstResponder() + } + } else if !focused, textView.isFirstResponder { + DispatchQueue.main.async { [weak self] in + self?.textView.resignFirstResponder() + } + } + } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + let w = bounds.width + guard w > 0 else { return } + + // Reply bar height + let replyH: CGFloat = isReplyVisible ? 46 : 0 // 6 top + 36 bar + 4 bottom + + // Text row height = textViewHeight (clamped) + let textRowH = textViewHeight + + // Input container inner height = padding + reply + text row + padding + let inputInnerH = innerPadding + replyH + textRowH + innerPadding + let inputContainerH = max(minInputContainerHeight, inputInnerH) + + // Main bar height + let mainBarH = max(buttonSize, inputContainerH) + + // Total height + let totalH = topPadding + mainBarH + bottomPadding + + // Attach button + let attachX = horizontalPadding + let attachY = topPadding + mainBarH - buttonSize + attachButton.frame = CGRect(x: attachX, y: attachY, width: buttonSize, height: buttonSize) + attachGlass.frame = attachButton.bounds + + // Center icon in attach button + centerIconLayer(in: attachButton, iconSize: CGSize(width: 21, height: 24)) + + // Mic button + let showMic = !isSendVisible + let micX = w - horizontalPadding - buttonSize + let micY = topPadding + mainBarH - buttonSize + micButton.frame = CGRect(x: micX, y: micY, width: buttonSize, height: buttonSize) + micGlass.frame = micButton.bounds + centerIconLayer(in: micButton, iconSize: CGSize(width: 18, height: 24)) + + // Input container + let inputX = attachX + buttonSize + innerSpacing + let micWidth: CGFloat = showMic ? (buttonSize + innerSpacing) : 0 + let inputW = w - inputX - horizontalPadding - micWidth + let inputY = topPadding + mainBarH - inputContainerH + inputContainer.frame = CGRect(x: inputX, y: inputY, width: inputW, height: inputContainerH) + inputGlass.frame = inputContainer.bounds + + let cornerRadius: CGFloat = isMultiline ? 16 : 21 + inputContainer.layer.cornerRadius = cornerRadius + inputContainer.layer.cornerCurve = .continuous + inputGlass.fixedCornerRadius = cornerRadius + inputGlass.applyCornerRadius() + + // Reply bar inside input container + let replyX: CGFloat = 6 + let replyW = inputW - replyX - 4 + replyBar.frame = CGRect(x: replyX, y: innerPadding, width: replyW, height: replyH) + layoutReplyBar(width: replyW, height: replyH) + + // Text view inside input container + let textX: CGFloat = innerPadding + 6 + let textY = innerPadding + replyH + let sendExtraW: CGFloat = isSendVisible ? sendButtonWidth : 0 + let emojiW: CGFloat = 20 + let emojiTrailing: CGFloat = 8 + sendExtraW + let textW = inputW - textX - emojiW - emojiTrailing - innerPadding + textView.frame = CGRect(x: textX, y: textY, width: max(0, textW), height: textRowH) + + // Emoji button + let emojiX = textX + textW + let emojiY = textY + textRowH - 36 + emojiButton.frame = CGRect(x: emojiX, y: emojiY, width: emojiW, height: 36) + centerIconLayer(in: emojiButton, iconSize: CGSize(width: 19, height: 19)) + + // Send button + let sendX = inputW - innerPadding - sendButtonWidth + let sendY = textY + textRowH - sendButtonHeight + sendButton.frame = CGRect(x: sendX, y: sendY, width: sendButtonWidth, height: sendButtonHeight) + sendCapsule.frame = sendButton.bounds + sendCapsule.layer.cornerRadius = sendButtonHeight / 2 + centerIconLayer(in: sendButton, iconSize: CGSize(width: 22, height: 19)) + + // Report height + if abs(totalH - currentHeight) > 0.5 { + currentHeight = totalH + delegate?.composerHeightDidChange(self, height: totalH) + } + } + + private func layoutReplyBar(width: CGFloat, height: CGFloat) { + guard height > 0 else { return } + let barX: CGFloat = 0 + let barY: CGFloat = 6 + replyBlueBar.frame = CGRect(x: barX, y: barY, width: 3, height: 36) + + let labelX: CGFloat = 3 + 8 + let titleSize = replyTitleLabel.sizeThatFits(CGSize(width: 200, height: 20)) + replyTitleLabel.frame = CGRect(x: labelX, y: barY + 2, width: titleSize.width, height: 17) + + let senderX = labelX + titleSize.width + let senderW = width - senderX - 34 + replySenderLabel.frame = CGRect(x: senderX, y: barY + 2, width: max(0, senderW), height: 17) + + replyPreviewLabel.frame = CGRect(x: labelX, y: barY + 19, width: width - labelX - 34, height: 17) + + replyCancelButton.frame = CGRect(x: width - 30, y: barY, width: 30, height: 36) + } + + // MARK: - Icon Helpers + + private func makeIconLayer( + pathData: String, + viewBox: CGSize, + targetSize: CGSize, + color: UIColor + ) -> CAShapeLayer { + var parser = SVGPathParser(pathData: pathData) + let cgPath = parser.parse() + + let layer = CAShapeLayer() + let sx = targetSize.width / viewBox.width + let sy = targetSize.height / viewBox.height + layer.path = cgPath.copy(using: [CGAffineTransform(scaleX: sx, y: sy)]) + layer.fillColor = color.cgColor + layer.bounds = CGRect(origin: .zero, size: targetSize) + return layer + } + + private func centerIconLayer(in button: UIView, iconSize: CGSize) { + for sublayer in button.layer.sublayers ?? [] { + guard let shape = sublayer as? CAShapeLayer else { continue } + CATransaction.begin() + CATransaction.setDisableActions(true) + shape.position = CGPoint(x: button.bounds.midX, y: button.bounds.midY) + CATransaction.commit() + break + } + } + + // MARK: - Text Height + + private func recalculateTextHeight() { + let lineHeight = textView.font?.lineHeight ?? 20 + let insets = textView.textContainerInset + let maxTextH = lineHeight * maxLines + let maxTotalH = maxTextH + insets.top + insets.bottom + + let fittingSize = textView.sizeThatFits( + CGSize(width: textView.bounds.width > 0 ? textView.bounds.width : (bounds.width - 150), height: .greatestFiniteMagnitude) + ) + let newH = max(textViewMinHeight, min(fittingSize.height, maxTotalH)) + + // Enable/disable scrolling + let shouldScroll = fittingSize.height > maxTotalH + if textView.isScrollEnabled != shouldScroll { + textView.isScrollEnabled = shouldScroll + } + + // Check multiline + let singleLineH = lineHeight + insets.top + insets.bottom + let threshold = singleLineH + lineHeight * 0.5 + let newMultiline = fittingSize.height > threshold + if newMultiline != isMultiline { + isMultiline = newMultiline + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + + if abs(newH - textViewHeight) > 0.5 { + textViewHeight = newH + setNeedsLayout() + } + } + + private func reportHeightIfChanged() { + let replyH: CGFloat = isReplyVisible ? 46 : 0 + let inputInnerH = innerPadding + replyH + textViewHeight + innerPadding + let inputContainerH = max(minInputContainerHeight, inputInnerH) + let mainBarH = max(buttonSize, inputContainerH) + let totalH = topPadding + mainBarH + bottomPadding + + if abs(totalH - currentHeight) > 0.5 { + currentHeight = totalH + delegate?.composerHeightDidChange(self, height: totalH) + } + } + + // MARK: - Send / Mic Visibility + + private func updateSendMicVisibility(animated: Bool) { + let hasContent = !(textView.text ?? "").isEmpty + guard hasContent != isSendVisible else { return } + isSendVisible = hasContent + + let duration: TimeInterval = animated ? 0.28 : 0 + let damping: CGFloat = 0.9 + + UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: damping, initialSpringVelocity: 0, options: .beginFromCurrentState) { + self.sendButton.alpha = hasContent ? 1 : 0 + self.sendButton.transform = hasContent ? .identity : CGAffineTransform(scaleX: 0.74, y: 0.74) + + self.micButton.alpha = hasContent ? 0 : 1 + self.micButton.transform = hasContent ? CGAffineTransform(scaleX: 0.42, y: 0.78) : .identity + + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + + // MARK: - UITextViewDelegate + + func textViewDidBeginEditing(_ textView: UITextView) { + delegate?.composerFocusDidChange(self, isFocused: true) + } + + func textViewDidEndEditing(_ textView: UITextView) { + delegate?.composerFocusDidChange(self, isFocused: false) + } + + func textView( + _ textView: UITextView, + shouldChangeTextIn range: NSRange, + replacementText text: String + ) -> Bool { + guard !text.isEmpty, text != "\n" else { return true } + delegate?.composerUserDidType(self) + return true + } + + func textViewDidChange(_ textView: UITextView) { + guard !isUpdatingText else { return } + self.textView.placeholderLabel.isHidden = !(textView.text ?? "").isEmpty + delegate?.composerTextDidChange(self, text: textView.text ?? "") + recalculateTextHeight() + updateSendMicVisibility(animated: true) + } + + // MARK: - Actions + + @objc private func attachTapped() { + delegate?.composerDidTapAttach(self) + } + + @objc private func sendTapped() { + delegate?.composerDidTapSend(self) + } + + @objc private func emojiTapped() { + // Emoji button = focus trigger (system emoji via 🌐 key) + if !textView.isFirstResponder { + textView.becomeFirstResponder() + } + } + + @objc private func micTapped() { + // Mic = placeholder for voice messages, acts as send when there's content + let text = (textView.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + delegate?.composerDidTapSend(self) + } else { + if !textView.isFirstResponder { + textView.becomeFirstResponder() + } + } + } + + @objc private func replyCancelTapped() { + delegate?.composerDidCancelReply(self) + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift index 1be1382..699776d 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -109,7 +109,7 @@ struct MessageCellView: View, Equatable { .lineSpacing(0) .fixedSize(horizontal: false, vertical: true) .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) + .padding(.trailing, outgoing ? 48 : 36) .padding(.vertical, 5) } .frame(minWidth: outgoing ? 86 : 66, alignment: .leading) @@ -232,7 +232,7 @@ struct MessageCellView: View, Equatable { .lineSpacing(0) .fixedSize(horizontal: false, vertical: true) .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) + .padding(.trailing, outgoing ? 48 : 36) .padding(.top, 3) .padding(.bottom, 5) } else if !hasVisualAttachments { @@ -241,7 +241,7 @@ struct MessageCellView: View, Equatable { .tracking(-0.43) .foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary) .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) + .padding(.trailing, outgoing ? 48 : 36) .padding(.top, 3) .padding(.bottom, 5) } else { @@ -332,7 +332,7 @@ struct MessageCellView: View, Equatable { .lineSpacing(0) .fixedSize(horizontal: false, vertical: true) .padding(.leading, 11) - .padding(.trailing, outgoing ? 64 : 48) + .padding(.trailing, outgoing ? 48 : 36) .padding(.top, 6) .padding(.bottom, 5) } @@ -810,15 +810,7 @@ struct MessageCellView: View, Equatable { // MARK: - Static Helpers static func isGarbageText(_ text: String) -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return true } - let validCharacters = trimmed.unicodeScalars.filter { scalar in - scalar.value != 0xFFFD && - scalar.value > 0x1F && - scalar.value != 0x7F && - !CharacterSet.controlCharacters.contains(scalar) - } - return validCharacters.isEmpty + MessageCellLayout.isGarbageOrEncrypted(text) } static func isValidCaption(_ text: String) -> Bool { diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift new file mode 100644 index 0000000..1beac0a --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -0,0 +1,577 @@ +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() + } + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift new file mode 100644 index 0000000..01051fd --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -0,0 +1,933 @@ +import SwiftUI +import UIKit + +// MARK: - NativeMessageListController + +/// UICollectionView-based message list with inverted scroll (newest at bottom). +/// Uses UIHostingConfiguration to render existing MessageCellView inside cells. +/// This gives UIKit scroll physics + SwiftUI rendering — best of both worlds. +/// +/// Phase 7: Telegram-style keyboard sync. No CADisplayLink, no presentationLayer. +/// Keyboard insets applied ONCE on notification (not 120x/sec). +/// Interactive dismiss tracked via KVO inputAccessoryView notification. +@MainActor +final class NativeMessageListController: UIViewController { + + // MARK: - Configuration + + struct Config { + var maxBubbleWidth: CGFloat + var currentPublicKey: String + var highlightedMessageId: String? + var isSavedMessages: Bool + var isSystemAccount: Bool + var opponentPublicKey: String + var opponentTitle: String + var opponentUsername: String + var actions: MessageCellActions + var firstUnreadMessageId: String? + } + + var config: Config + + // MARK: - Callbacks + + var onScrollToBottomVisibilityChange: ((Bool) -> Void)? + var onPaginationTrigger: (() -> Void)? + var onTapBackground: (() -> Void)? + var onComposerHeightChange: ((CGFloat) -> Void)? + var onKeyboardDidHide: (() -> Void)? + + // Composer callbacks (forwarded from ComposerViewDelegate) + var onComposerSend: (() -> Void)? + var onComposerAttach: (() -> Void)? + var onComposerTextChange: ((String) -> Void)? + var onComposerFocusChange: ((Bool) -> Void)? + var onComposerReplyCancel: (() -> Void)? + var onComposerTyping: (() -> Void)? + + // MARK: - State + + private(set) var messages: [ChatMessage] = [] + var hasMoreMessages: Bool = true + + // MARK: - UIKit + + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private var nativeCellRegistration: UICollectionView.CellRegistration! + + // MARK: - Composer + + /// Pure UIKit composer (iOS < 26). nil on iOS 26+ (SwiftUI overlay). + private(set) var composerView: ComposerView? + private var lastComposerHeight: CGFloat = 0 + private var hasAppliedInitialInsets = false + private var shouldSetupComposer = false + + // MARK: - Keyboard State (Telegram-style manual tracking) + private var composerBottomConstraint: NSLayoutConstraint? + private var composerHeightConstraint: NSLayoutConstraint? + private var isKeyboardAnimating = false + private var currentKeyboardHeight: CGFloat = 0 + + // MARK: - Scroll-to-Bottom Button + private var scrollToBottomButton: UIButton? + /// Dedup for scrollViewDidScroll → onScrollToBottomVisibilityChange callback. + private var lastReportedAtBottom: Bool = true + + // MARK: - Layout Cache (Telegram asyncLayout pattern) + + /// Cache: messageId → pre-calculated layout from background thread. + /// All frame rects computed once, applied on main thread (just sets frames). + private var layoutCache: [String: MessageCellLayout] = [:] + + // MARK: - Init + + init(config: Config) { + self.config = config + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupCollectionView() + setupNativeCellRegistration() + setupDataSource() + + // Create pure UIKit composer after view hierarchy is ready. + if shouldSetupComposer { + shouldSetupComposer = false + performSetupComposer() + } + + // Telegram-style keyboard: single notification, manual constraint animation. + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardWillChangeFrame(_:)), + name: UIResponder.keyboardWillChangeFrameNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(keyboardDidHide), + name: UIResponder.keyboardDidHideNotification, object: nil + ) + NotificationCenter.default.addObserver( + self, selector: #selector(interactiveKeyboardHeightChanged(_:)), + name: NSNotification.Name("InteractiveKeyboardHeightChanged"), object: nil + ) + } + + // Phase 7: No viewWillAppear/viewWillDisappear — CADisplayLink removed entirely. + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if !hasAppliedInitialInsets { + applyInsets() + hasAppliedInitialInsets = true + } + // ComposerView reports height via delegate (composerHeightDidChange). + // No polling needed — pure UIKit, no UIHostingController inflation bug. + } + + override func viewSafeAreaInsetsDidChange() { + super.viewSafeAreaInsetsDidChange() + applyInsets() + // Update composer bottom when keyboard is hidden + if currentKeyboardHeight == 0 { + composerBottomConstraint?.constant = -view.safeAreaInsets.bottom + } + } + + // MARK: - Setup + + private func setupCollectionView() { + var listConfig = UICollectionLayoutListConfiguration(appearance: .plain) + listConfig.showsSeparators = false + listConfig.backgroundColor = .clear + let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in + let section = NSCollectionLayoutSection.list(using: listConfig, layoutEnvironment: environment) + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10) + return section + } + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.backgroundColor = .clear + collectionView.delegate = self + collectionView.keyboardDismissMode = .interactive + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.alwaysBounceHorizontal = false + + // Inversion: flip the entire collection view. + // Each cell is flipped back in UIHostingConfiguration. + collectionView.transform = CGAffineTransform(scaleX: 1, y: -1) + + // We manage insets manually for composer/nav bar overlap. + collectionView.contentInsetAdjustmentBehavior = .never + + view.addSubview(collectionView) + + // Full-screen collectionView — extends BEHIND composer for glass/blur effect. + // Visible area controlled by contentInset (composer + keyboard). + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + // Tap to dismiss keyboard + let tap = UITapGestureRecognizer(target: self, action: #selector(handleBackgroundTap)) + tap.cancelsTouchesInView = false + collectionView.addGestureRecognizer(tap) + } + + private func setupNativeCellRegistration() { + nativeCellRegistration = UICollectionView.CellRegistration { + [weak self] cell, indexPath, message in + guard let self else { return } + + // Apply pre-calculated layout (just sets frames — no computation) + if let layout = self.layoutCache[message.id] { + cell.apply(layout: layout) + } + + // Parse reply data for quote display + let replyAtt = message.attachments.first { $0.type == .messages } + var replyName: String? + var replyText: String? + var forwardSenderName: String? + + if let att = replyAtt { + if let data = att.blob.data(using: .utf8), + let replies = try? JSONDecoder().decode([ReplyMessageData].self, from: data), + let first = replies.first { + let senderKey = first.publicKey + let name: String + if senderKey == self.config.currentPublicKey { + name = "You" + } else if senderKey == self.config.opponentPublicKey { + name = self.config.opponentTitle.isEmpty + ? String(senderKey.prefix(8)) + "…" + : self.config.opponentTitle + } else { + name = DialogRepository.shared.dialogs[senderKey]?.opponentTitle + ?? String(senderKey.prefix(8)) + "…" + } + + let displayText = MessageCellLayout.isGarbageOrEncrypted(message.text) ? "" : message.text + if displayText.isEmpty { + // Forward + forwardSenderName = name + } else { + // Reply quote + replyName = name + replyText = first.message.isEmpty ? "Photo" : first.message + } + } + } + + cell.configure( + message: message, + timestamp: self.formatTimestamp(message.timestamp), + actions: self.config.actions, + replyName: replyName, + replyText: replyText, + forwardSenderName: forwardSenderName + ) + } + } + + /// Format timestamp from milliseconds to "HH:mm" string. + private static let timestampFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm" + return f + }() + + private func formatTimestamp(_ ms: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) + return Self.timestampFormatter.string(from: date) + } + + private func setupDataSource() { + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { [weak self] collectionView, indexPath, messageId in + guard let self else { return UICollectionViewCell() } + + let reversedIndex = indexPath.item + guard reversedIndex < self.messages.count else { return UICollectionViewCell() } + + let messageIndex = self.messages.count - 1 - reversedIndex + let message = self.messages[messageIndex] + + // ALL messages use pure UIKit NativeMessageCell (no SwiftUI) + return collectionView.dequeueConfiguredReusableCell( + using: self.nativeCellRegistration, + for: indexPath, + item: message + ) + } + } + + // MARK: - Composer Setup + + /// Creates pure UIKit ComposerView pinned to bottom. + /// Safe to call before viewDidLoad — defers until view hierarchy is ready. + func setupComposer() { + if !isViewLoaded { + shouldSetupComposer = true + return + } + performSetupComposer() + } + + private func performSetupComposer() { + let composer = ComposerView(frame: .zero) + composer.delegate = self + composer.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(composer) + + let initialH: CGFloat = 60 + let heightC = composer.heightAnchor.constraint(equalToConstant: initialH) + composerHeightConstraint = heightC + lastComposerHeight = initialH + + let bottomC = composer.bottomAnchor.constraint( + equalTo: view.bottomAnchor, + constant: -view.safeAreaInsets.bottom + ) + composerBottomConstraint = bottomC + + NSLayoutConstraint.activate([ + composer.leadingAnchor.constraint(equalTo: view.leadingAnchor), + composer.trailingAnchor.constraint(equalTo: view.trailingAnchor), + heightC, + bottomC, + ]) + + composerView = composer + setupScrollToBottomButton(above: composer) + applyInsets() + } + + // MARK: - Scroll-to-Bottom Button (UIKit, pinned to composer) + + private func setupScrollToBottomButton(above composer: UIView) { + let size: CGFloat = 42 + let rect = CGRect(x: 0, y: 0, width: size, height: size) + + // Container: Auto Layout positions it, clipsToBounds prevents overflow. + // Nothing inside the container uses Auto Layout or UIView.transform. + let container = UIView(frame: .zero) + container.translatesAutoresizingMaskIntoConstraints = false + container.backgroundColor = .clear + container.clipsToBounds = true + container.isUserInteractionEnabled = true + view.addSubview(container) + + NSLayoutConstraint.activate([ + container.widthAnchor.constraint(equalToConstant: size), + container.heightAnchor.constraint(equalToConstant: size), + container.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + container.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -76), + ]) + + // Button: hardcoded 42×42 frame. NO UIView.transform — scale is done + // at CALayer level so UIKit never recalculates bounds through the + // transform matrix during interactive keyboard dismiss. + let button = UIButton(type: .custom) + button.frame = rect + button.clipsToBounds = true + button.alpha = 0 + button.layer.transform = CATransform3DMakeScale(0.01, 0.01, 1.0) + button.layer.allowsEdgeAntialiasing = true + container.addSubview(button) + + // Glass circle background: hardcoded 42×42 frame, no autoresizingMask. + let glass = TelegramGlassUIView(frame: rect) + glass.isCircle = true + glass.isUserInteractionEnabled = false + button.addSubview(glass) + + // Chevron down icon: hardcoded 42×42 frame, centered contentMode. + let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .semibold) + let chevron = UIImage(systemName: "chevron.down", withConfiguration: config) + let imageView = UIImageView(image: chevron) + imageView.tintColor = .white + imageView.contentMode = .center + imageView.frame = rect + button.addSubview(imageView) + + button.addTarget(self, action: #selector(scrollToBottomTapped), for: .touchUpInside) + scrollToBottomButton = button + } + + @objc private func scrollToBottomTapped() { + scrollToBottom(animated: true) + onScrollToBottomVisibilityChange?(true) + } + + /// Show/hide the scroll-to-bottom button with CALayer-level scaling. + /// UIView.bounds stays 42×42 at ALL times — only rendered pixels scale. + /// No UIView.transform, no layoutIfNeeded — completely bypasses the + /// Auto Layout ↔ transform race condition during interactive dismiss. + func setScrollToBottomVisible(_ visible: Bool) { + guard let button = scrollToBottomButton else { return } + let isCurrentlyVisible = button.alpha > 0.5 + guard visible != isCurrentlyVisible else { return } + + UIView.animate(withDuration: visible ? 0.25 : 0.2, delay: 0, + usingSpringWithDamping: 0.8, initialSpringVelocity: 0, + options: .beginFromCurrentState) { + button.alpha = visible ? 1 : 0 + button.layer.transform = visible + ? CATransform3DIdentity + : CATransform3DMakeScale(0.01, 0.01, 1.0) + } + } + + // MARK: - Update + + /// Called from SwiftUI when messages array changes. + func update(messages: [ChatMessage], animated: Bool = false) { + self.messages = messages + + // Pre-calculate layouts (Telegram asyncLayout pattern). + // TODO: Move to background thread for full Telegram parity. + // Currently on main thread (still fast — C++ math + CoreText). + calculateLayouts() + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(messages.reversed().map(\.id)) + dataSource.apply(snapshot, animatingDifferences: animated) + } + + // MARK: - Layout Calculation (Telegram asyncLayout pattern) + + /// Pre-calculate layouts for NEW messages only (skip cached). + private func calculateLayouts() { + let existingIds = Set(layoutCache.keys) + let newMessages = messages.filter { !existingIds.contains($0.id) } + guard !newMessages.isEmpty else { return } + + let newLayouts = MessageCellLayout.batchCalculate( + messages: newMessages, + maxBubbleWidth: config.maxBubbleWidth, + currentPublicKey: config.currentPublicKey, + opponentPublicKey: config.opponentPublicKey, + opponentTitle: config.opponentTitle + ) + layoutCache.merge(newLayouts) { _, new in new } + } + + // MARK: - Inset Management + + /// Update content insets for composer overlap + keyboard. + /// CollectionView extends full-screen (behind composer for glass/blur). + /// contentInset.top (= visual bottom in inverted scroll) = composer space. + /// + /// Compensation rules for inverted scroll: + /// - delta > 0 (keyboard opening / composer growing): ALWAYS compensate + /// offset so content rides up with keyboard, preserving reading context. + /// - delta < 0 (keyboard closing / composer shrinking): do NOTHING. + /// UIScrollView automatically clamps contentOffset.y to the new minimum + /// (-contentInset.top) with smooth animation. Manual compensation here + /// would double the adjustment → content teleports upward. + private func applyInsets() { + guard collectionView != nil else { return } + + let composerBottom = max(currentKeyboardHeight, view.safeAreaInsets.bottom) + let composerHeight = lastComposerHeight + let newInsetTop = composerHeight + composerBottom + let topInset = view.safeAreaInsets.top + 6 + + let oldInsetTop = collectionView.contentInset.top + let delta = newInsetTop - oldInsetTop + + // Capture offset BEFORE setting insets — UIKit may auto-clamp after. + let oldOffset = collectionView.contentOffset.y + + collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0) + collectionView.scrollIndicatorInsets = collectionView.contentInset + + // Always compensate symmetrically: open AND close. + // Setting exact `oldOffset - delta` overrides any UIKit auto-clamping. + let shouldCompensate = abs(delta) > 0.5 + && !collectionView.isDragging + && !collectionView.isDecelerating + if shouldCompensate { + collectionView.contentOffset.y = oldOffset - delta + } + } + + /// Scroll to the newest message (visual bottom = offset 0 in inverted scroll). + func scrollToBottom(animated: Bool) { + guard !messages.isEmpty else { return } + collectionView.setContentOffset( + CGPoint(x: 0, y: -collectionView.contentInset.top), + animated: animated + ) + } + + /// Scroll to a specific message by ID (for reply quote navigation). + func scrollToMessage(id: String, animated: Bool) { + guard let snapshot = dataSource?.snapshot(), + let itemIndex = snapshot.indexOfItem(id) else { return } + let indexPath = IndexPath(item: itemIndex, section: 0) + collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: animated) + } + + /// Reconfigure visible cells without rebuilding the snapshot. + func reconfigureVisibleCells() { + var snapshot = dataSource.snapshot() + let visibleIds = collectionView.indexPathsForVisibleItems.compactMap { + dataSource.itemIdentifier(for: $0) + } + snapshot.reconfigureItems(visibleIds) + dataSource.apply(snapshot, animatingDifferences: false) + } + + // MARK: - Bubble Position + + private func bubblePosition(for message: ChatMessage, at reversedIndex: Int) -> BubblePosition { + let chronoIndex = messages.count - 1 - reversedIndex + guard chronoIndex >= 0, chronoIndex < messages.count else { return .single } + + let currentIsFromMe = message.isFromMe(myPublicKey: config.currentPublicKey) + + let hasPrev: Bool = { + guard chronoIndex > 0 else { return false } + let prev = messages[chronoIndex - 1] + return prev.isFromMe(myPublicKey: config.currentPublicKey) == currentIsFromMe + }() + + let hasNext: Bool = { + guard chronoIndex + 1 < messages.count else { return false } + let next = messages[chronoIndex + 1] + return next.isFromMe(myPublicKey: config.currentPublicKey) == currentIsFromMe + }() + + switch (hasPrev, hasNext) { + case (false, false): return .single + case (false, true): return .top + case (true, true): return .mid + case (true, false): return .bottom + } + } + + // MARK: - Actions + + @objc private func handleBackgroundTap() { + onTapBackground?() + } + + // MARK: - Telegram-Style Keyboard Handler + + /// Single handler for keyboard show/hide/resize (Telegram pattern). + /// Extracts height, duration, curve from notification → animates ONE constraint. + @objc private func keyboardWillChangeFrame(_ notification: Notification) { + guard let info = notification.userInfo, + let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + else { return } + + #if DEBUG + let screenH = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height + let kbH = max(0, screenH - endFrame.minY) + print("⌨️ keyboardWillChange | kbHeight=\(kbH) endFrame=\(endFrame) composerH=\(lastComposerHeight) currentInset=\(collectionView?.contentInset.top ?? 0)") + #endif + + let screenHeight = view.window?.screen.bounds.height ?? UIScreen.main.bounds.height + let keyboardHeight = max(0, screenHeight - endFrame.minY) + let safeBottom = view.safeAreaInsets.bottom + let composerBottom = max(keyboardHeight, safeBottom) + + let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? 0.25 + let curve = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? 7 + + currentKeyboardHeight = keyboardHeight + isKeyboardAnimating = true + + // Telegram pattern: animate composer position + content insets in ONE block. + // Explicit composerHeightConstraint prevents the 372pt inflation bug. + let composerH = lastComposerHeight + let newInsetTop = composerH + composerBottom + let topInset = view.safeAreaInsets.top + 6 + let oldInsetTop = collectionView.contentInset.top + let delta = newInsetTop - oldInsetTop + + // Capture offset BEFORE animation — UIKit may auto-clamp after inset change. + let oldOffset = collectionView.contentOffset.y + let shouldCompensate = abs(delta) > 0.5 + && !collectionView.isDragging + && !collectionView.isDecelerating + + let options = UIView.AnimationOptions(rawValue: curve << 16).union(.beginFromCurrentState) + UIView.animate(withDuration: duration, delay: 0, options: options, animations: { + self.composerBottomConstraint?.constant = -composerBottom + + self.collectionView.contentInset = UIEdgeInsets(top: newInsetTop, left: 0, bottom: topInset, right: 0) + self.collectionView.scrollIndicatorInsets = self.collectionView.contentInset + + // Enforce exact offset — overrides any UIKit auto-clamping. + if shouldCompensate { + self.collectionView.contentOffset.y = oldOffset - delta + } + + self.view.layoutIfNeeded() + }, completion: { _ in + self.isKeyboardAnimating = false + }) + } + + /// Interactive dismiss — follows finger frame-by-frame. + /// CRITICAL: Only process when user is actively dragging the collectionView. + /// The inputAccessoryView KVO fires for ALL keyboard frame changes, including + /// the system open/close animation. Without this guard, KVO races ahead of + /// keyboardWillChangeFrame and steals the animation delta (sets insets to the + /// target value instantly → the notification's delta becomes 0 → no offset + /// compensation → content stays still while composer moves). + @objc private func interactiveKeyboardHeightChanged(_ notification: Notification) { + // Reject if system is animating keyboard (let keyboardWillChangeFrame handle it) + guard !isKeyboardAnimating else { return } + // Reject if user is NOT actively dragging — this KVO is a side-effect + // of the system keyboard show/hide, not a genuine interactive dismiss + guard collectionView.isDragging || collectionView.isTracking else { return } + guard let height = notification.userInfo?["height"] as? CGFloat else { return } + + currentKeyboardHeight = height + let composerBottom = max(height, view.safeAreaInsets.bottom) + composerBottomConstraint?.constant = -composerBottom + applyInsets() + } + + @objc private func keyboardDidHide() { + #if DEBUG + print("⌨️ didHide | cv.frame=\(collectionView?.frame ?? .zero) composer.frame=\(composerView?.frame ?? .zero) offset=\(collectionView?.contentOffset ?? .zero) composerConst=\(composerBottomConstraint?.constant ?? 0)") + #endif + currentKeyboardHeight = 0 + isKeyboardAnimating = false + onKeyboardDidHide?() + } +} + +// MARK: - UICollectionViewDelegate + +extension NativeMessageListController: UICollectionViewDelegate { + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offsetFromBottom = scrollView.contentOffset.y + scrollView.contentInset.top + let isAtBottom = offsetFromBottom < 50 + + // Dedup — only fire when value actually changes. + // Without this, callback fires 60fps during keyboard animation + // → @State update → SwiftUI re-render → layout loop → Hang. + if isAtBottom != lastReportedAtBottom { + lastReportedAtBottom = isAtBottom + onScrollToBottomVisibilityChange?(isAtBottom) + setScrollToBottomVisible(!isAtBottom) + } + + let offsetFromTop = scrollView.contentSize.height - scrollView.contentOffset.y - scrollView.bounds.height + if offsetFromTop < 200, hasMoreMessages { + onPaginationTrigger?() + } + } +} + +// MARK: - ComposerViewDelegate + +extension NativeMessageListController: ComposerViewDelegate { + + func composerDidTapSend(_ composer: ComposerView) { + onComposerSend?() + } + + func composerDidTapAttach(_ composer: ComposerView) { + onComposerAttach?() + } + + func composerTextDidChange(_ composer: ComposerView, text: String) { + onComposerTextChange?(text) + } + + func composerFocusDidChange(_ composer: ComposerView, isFocused: Bool) { + onComposerFocusChange?(isFocused) + } + + func composerHeightDidChange(_ composer: ComposerView, height: CGFloat) { + guard abs(height - lastComposerHeight) > 0.5 else { return } + lastComposerHeight = height + composerHeightConstraint?.constant = height + onComposerHeightChange?(height) + // applyInsets() calculates the delta and compensates contentOffset. + // No manual offset adjustment here — applyInsets() handles it uniformly. + applyInsets() + } + + func composerDidCancelReply(_ composer: ComposerView) { + onComposerReplyCancel?() + } + + func composerUserDidType(_ composer: ComposerView) { + onComposerTyping?() + } + + func composerKeyboardHeightDidChange(_ composer: ComposerView, height: CGFloat) { + // Route interactive dismiss KVO to the same notification handler + // that NativeMessageListController already uses. + NotificationCenter.default.post( + name: NSNotification.Name("InteractiveKeyboardHeightChanged"), + object: nil, + userInfo: ["height": height] + ) + } +} + +// MARK: - PreSizedCell + +/// UICollectionViewCell subclass that uses pre-calculated heights from C++ engine. +/// When `preCalculatedHeight` is set, `preferredLayoutAttributesFitting` returns it +/// immediately — skipping the expensive SwiftUI self-sizing layout pass. +final class PreSizedCell: UICollectionViewCell { + /// Height from C++ MessageLayout engine. Set in cell registration closure. + var preCalculatedHeight: CGFloat? + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + if let h = preCalculatedHeight { + let attrs = layoutAttributes.copy() as! UICollectionViewLayoutAttributes + attrs.size.height = h + return attrs + } + // Fallback: UIHostingConfiguration self-sizing + return super.preferredLayoutAttributesFitting(layoutAttributes) + } + + override func prepareForReuse() { + super.prepareForReuse() + preCalculatedHeight = nil + } +} + +// MARK: - NativeUnreadSeparator + +private struct NativeUnreadSeparator: View { + var body: some View { + HStack(spacing: 8) { + Rectangle() + .fill(RosettaColors.Adaptive.textTertiary.opacity(0.4)) + .frame(height: 0.5) + Text("Unread Messages") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) + Rectangle() + .fill(RosettaColors.Adaptive.textTertiary.opacity(0.4)) + .frame(height: 0.5) + } + .padding(.vertical, 8) + .padding(.horizontal, 6) + } +} + +// MARK: - NativeMessageListView (UIViewControllerRepresentable) + +/// SwiftUI bridge for NativeMessageListController. +struct NativeMessageListView: UIViewControllerRepresentable { + let messages: [ChatMessage] + let maxBubbleWidth: CGFloat + let currentPublicKey: String + let highlightedMessageId: String? + let route: ChatRoute + let actions: MessageCellActions + let hasMoreMessages: Bool + let firstUnreadMessageId: String? + /// true = create UIKit ComposerView (iOS < 26). false = iOS 26+ (SwiftUI overlay). + let useUIKitComposer: Bool + var scrollToMessageId: String? + var shouldScrollToBottom: Bool = false + @Binding var scrollToBottomRequested: Bool + var onAtBottomChange: ((Bool) -> Void)? + var onPaginate: (() -> Void)? + var onTapBackground: (() -> Void)? + var onNewMessageAutoScroll: (() -> Void)? + var onComposerHeightChange: ((CGFloat) -> Void)? + var onKeyboardDidHide: (() -> Void)? + + // Composer state (iOS < 26, forwarded to ComposerView) + @Binding var messageText: String + @Binding var isInputFocused: Bool + var replySenderName: String? + var replyPreviewText: String? + var onSend: (() -> Void)? + var onAttach: (() -> Void)? + var onTyping: (() -> Void)? + var onReplyCancel: (() -> Void)? + + func makeUIViewController(context: Context) -> NativeMessageListController { + let config = NativeMessageListController.Config( + maxBubbleWidth: maxBubbleWidth, + currentPublicKey: currentPublicKey, + highlightedMessageId: highlightedMessageId, + isSavedMessages: route.isSavedMessages, + isSystemAccount: route.isSystemAccount, + opponentPublicKey: route.publicKey, + opponentTitle: route.title, + opponentUsername: route.username, + actions: actions, + firstUnreadMessageId: firstUnreadMessageId + ) + + let controller = NativeMessageListController(config: config) + controller.hasMoreMessages = hasMoreMessages + + // Create pure UIKit composer (iOS < 26) + if useUIKitComposer { + controller.setupComposer() + } + + wireCallbacks(controller, context: context) + + // Force view load so dataSource/collectionView are initialized. + controller.loadViewIfNeeded() + controller.update(messages: messages) + + // Apply initial composer state + if useUIKitComposer { + syncComposerState(controller) + } + + return controller + } + + func updateUIViewController(_ controller: NativeMessageListController, context: Context) { + let coordinator = context.coordinator + + // Update config + var configChanged = false + if controller.config.maxBubbleWidth != maxBubbleWidth { + controller.config.maxBubbleWidth = maxBubbleWidth + configChanged = true + } + if controller.config.highlightedMessageId != highlightedMessageId { + controller.config.highlightedMessageId = highlightedMessageId + configChanged = true + } + if controller.config.firstUnreadMessageId != firstUnreadMessageId { + controller.config.firstUnreadMessageId = firstUnreadMessageId + configChanged = true + } + + controller.hasMoreMessages = hasMoreMessages + + wireCallbacks(controller, context: context) + + // Sync composer state (iOS < 26) + if useUIKitComposer { + syncComposerState(controller) + } + + // Update messages + let messagesChanged = coordinator.lastMessageFingerprint != messageFingerprint + if messagesChanged { + let wasAtBottom = coordinator.isAtBottom + let lastMessageId = coordinator.lastNewestMessageId + let newNewestId = messages.last?.id + let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true + + controller.update(messages: messages) + coordinator.lastMessageFingerprint = messageFingerprint + coordinator.lastNewestMessageId = newNewestId + + if (wasAtBottom || shouldScrollToBottom || lastIsOutgoing), + newNewestId != lastMessageId, newNewestId != nil { + DispatchQueue.main.async { + controller.scrollToBottom(animated: true) + } + onNewMessageAutoScroll?() + } + } else if configChanged { + controller.reconfigureVisibleCells() + } + + // Scroll-to-bottom button request + if scrollToBottomRequested { + DispatchQueue.main.async { + controller.scrollToBottom(animated: true) + scrollToBottomRequested = false + } + } + + // Scroll-to-message request + if let targetId = scrollToMessageId, targetId != coordinator.lastScrollTargetId { + coordinator.lastScrollTargetId = targetId + DispatchQueue.main.async { + controller.scrollToMessage(id: targetId, animated: true) + } + } + } + + private func wireCallbacks(_ controller: NativeMessageListController, context: Context) { + let coordinator = context.coordinator + + controller.onScrollToBottomVisibilityChange = { [weak coordinator] isAtBottom in + coordinator?.isAtBottom = isAtBottom + DispatchQueue.main.async { onAtBottomChange?(isAtBottom) } + } + controller.onPaginationTrigger = { onPaginate?() } + controller.onTapBackground = { onTapBackground?() } + controller.onComposerHeightChange = { h in + DispatchQueue.main.async { onComposerHeightChange?(h) } + } + controller.onKeyboardDidHide = { onKeyboardDidHide?() } + + // Composer callbacks → SwiftUI state + controller.onComposerSend = { onSend?() } + controller.onComposerAttach = { onAttach?() } + controller.onComposerTextChange = { text in + DispatchQueue.main.async { messageText = text } + } + controller.onComposerFocusChange = { focused in + DispatchQueue.main.async { isInputFocused = focused } + } + controller.onComposerReplyCancel = { onReplyCancel?() } + controller.onComposerTyping = { onTyping?() } + } + + private func syncComposerState(_ controller: NativeMessageListController) { + guard let composer = controller.composerView else { return } + composer.setText(messageText) + composer.setReply(senderName: replySenderName, previewText: replyPreviewText) + composer.setFocused(isInputFocused) + } + + // MARK: - Fingerprint + + private var messageFingerprint: String { + guard let first = messages.first, let last = messages.last else { return "empty" } + return "\(messages.count)_\(first.id)_\(last.id)_\(last.deliveryStatus.rawValue)_\(last.isRead)" + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var lastMessageFingerprint: String = "" + var lastNewestMessageId: String? + var lastScrollTargetId: String? + var isAtBottom: Bool = true + } +} diff --git a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift new file mode 100644 index 0000000..19a59f7 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift @@ -0,0 +1,491 @@ +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 + } +} diff --git a/Rosetta/Rosetta-Bridging-Header.h b/Rosetta/Rosetta-Bridging-Header.h new file mode 100644 index 0000000..2b0a662 --- /dev/null +++ b/Rosetta/Rosetta-Bridging-Header.h @@ -0,0 +1,9 @@ +// +// Rosetta-Bridging-Header.h +// Rosetta +// +// Objective-C/C++ bridge for Swift interop. +// Exposes C++ layout engine via Objective-C++ wrappers. +// + +#import "Core/Layout/MessageLayoutBridge.h"