Фикс клавиатуры iOS < 26: pure UIKit composer, симметричная компенсация offset, scroll-to-bottom на CALayer
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,6 +7,7 @@ CLAUDE.md
|
||||
.claude.local.md
|
||||
desktop
|
||||
server
|
||||
Telegram-iOS
|
||||
AGENTS.md
|
||||
|
||||
# Xcode
|
||||
|
||||
@@ -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;
|
||||
|
||||
504
Rosetta/Core/Layout/MessageCellLayout.swift
Normal file
504
Rosetta/Core/Layout/MessageCellLayout.swift
Normal 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
|
||||
}
|
||||
}
|
||||
115
Rosetta/Core/Layout/MessageLayout.cpp
Normal file
115
Rosetta/Core/Layout/MessageLayout.cpp
Normal 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
|
||||
55
Rosetta/Core/Layout/MessageLayout.hpp
Normal file
55
Rosetta/Core/Layout/MessageLayout.hpp
Normal 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
|
||||
38
Rosetta/Core/Layout/MessageLayoutBridge.h
Normal file
38
Rosetta/Core/Layout/MessageLayoutBridge.h
Normal 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
|
||||
112
Rosetta/Core/Layout/MessageLayoutBridge.mm
Normal file
112
Rosetta/Core/Layout/MessageLayoutBridge.mm
Normal 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
549
Rosetta/Features/Chats/ChatDetail/ComposerView.swift
Normal file
549
Rosetta/Features/Chats/ChatDetail/ComposerView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
577
Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
Normal file
577
Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
933
Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift
Normal file
933
Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift
Normal 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
|
||||
}
|
||||
}
|
||||
491
Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift
Normal file
491
Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift
Normal 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
|
||||
}
|
||||
}
|
||||
9
Rosetta/Rosetta-Bridging-Header.h
Normal file
9
Rosetta/Rosetta-Bridging-Header.h
Normal 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"
|
||||
Reference in New Issue
Block a user