Фикс клавиатуры iOS < 26: pure UIKit composer, симметричная компенсация offset, scroll-to-bottom на CALayer

This commit is contained in:
2026-03-27 01:01:06 +05:00
parent f6afc79cd8
commit 3a3489ac49
15 changed files with 3486 additions and 575 deletions

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ CLAUDE.md
.claude.local.md
desktop
server
Telegram-iOS
AGENTS.md
# Xcode

View File

@@ -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;

View File

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

View File

@@ -0,0 +1,115 @@
#include "MessageLayout.hpp"
#include <algorithm>
#include <cmath>
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

View File

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

View File

@@ -0,0 +1,38 @@
#import <UIKit/UIKit.h>
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

View File

@@ -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<float>(maxWidth);
input.textHeight = static_cast<float>(textH);
input.hasText = (text.length > 0);
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(position);
input.hasReplyQuote = hasReplyQuote;
input.isForward = false;
input.imageCount = 0;
input.fileCount = 0;
input.avatarCount = 0;
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(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<float>(maxWidth);
input.textHeight = static_cast<float>(captionH);
input.hasText = (caption && caption.length > 0);
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(position);
input.hasReplyQuote = false;
input.isForward = false;
input.imageCount = imageCount;
input.fileCount = fileCount;
input.avatarCount = avatarCount;
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(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<float>(maxWidth);
input.textHeight = 0;
input.hasText = false;
input.isOutgoing = isOutgoing;
input.position = static_cast<rosetta::BubblePosition>(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<float>(captionH);
auto result = rosetta::calculateLayout(input);
return static_cast<CGFloat>(result.totalHeight);
}
@end

View File

@@ -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<Content: View, Composer: View>: 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<Content, Composer> {
let vc = _KeyboardSyncedVC(content: content, composer: composer)
vc.onComposerHeightChange = onComposerHeightChange
vc.onTopSafeAreaChange = onTopSafeAreaChange
return vc
}
func updateUIViewController(
_ vc: _KeyboardSyncedVC<Content, Composer>,
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<Content: View, Composer: View>: UIViewController, UIGestureRecognizerDelegate {
let listController: UIHostingController<Content>
let composerController: UIHostingController<Composer>
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)
}
}
}
}

View File

@@ -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,
NativeMessageListView(
messages: messages,
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 {
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() }
}
}
}
}
.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
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
if shouldScrollOnNextMessage || lastIsOutgoing || isAtBottom {
DispatchQueue.main.async {
scrollToBottom(proxy: proxy, animated: true)
}
},
onTapBackground: {
isInputFocused = false
},
onNewMessageAutoScroll: {
shouldScrollOnNextMessage = false
}
let lastIsOutgoing = messages.last?.isFromMe(myPublicKey: currentPublicKey) == true
if isViewActive && !lastIsOutgoing
&& !route.isSavedMessages && !route.isSystemAccount {
markDialogAsRead()
}
}
// Scroll-to-reply: navigate to the original message and highlight it briefly.
},
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 }
scrollToMessageId = nil
withAnimation(.easeInOut(duration: 0.3)) {
proxy.scrollTo(targetId, anchor: .center)
}
// NativeMessageListView handles the actual scroll via scrollToMessageId param.
// Here we only manage the highlight animation.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
withAnimation(.easeIn(duration: 0.2)) {
highlightedMessageId = targetId
}
scrollToMessageId = nil
withAnimation(.easeIn(duration: 0.2)) { highlightedMessageId = targetId }
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 0.5)) {
highlightedMessageId = nil
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<C: View>: 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).

View File

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

View File

@@ -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 {

View File

@@ -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()
}
}
}

View File

@@ -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<Int, String>!
private var nativeCellRegistration: UICollectionView.CellRegistration<NativeMessageCell, ChatMessage>!
// 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<NativeMessageCell, ChatMessage> {
[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<Int, String>(
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<Int, String>()
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
}
}

View File

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

View File

@@ -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"