Equatable-ячейки сообщений, пагинация скролла, оптимизация removeDuplicates

This commit is contained in:
2026-03-25 15:06:01 +05:00
parent d482cdf62b
commit d0041f0c10
9 changed files with 1307 additions and 1258 deletions

View File

@@ -421,7 +421,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -437,7 +437,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.4;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -460,7 +460,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 25;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.4;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -33,8 +33,10 @@ final class MessageRepository: ObservableObject {
/// Page size for initial message loading. Android: PAGE_SIZE = 30.
static let pageSize = 50
/// Max messages per dialog in memory cache. Android: MAX_CACHE_SIZE = 500.
static let maxCacheSize = 500
/// Max messages per dialog in memory cache.
/// Increased from 500 to 3000 for pagination support.
/// With .equatable() cells, only ~15 visible cells render RAM impact is ~600 KB.
static let maxCacheSize = 3000
private var db: DatabaseManager { DatabaseManager.shared }

View File

@@ -11,17 +11,17 @@ enum ReleaseNotes {
Entry(
version: appVersion,
body: """
**Производительность скролла**
Ячейки сообщений извлечены в отдельный Equatable-компонент — SwiftUI пропускает перерисовку неизменённых ячеек. Плавный скролл на 120 FPS даже в длинных переписках.
**Пагинация**
История чата подгружается порциями по 50 сообщений при скролле вверх. Можно листать на тысячи сообщений назад без задержек.
**Клавиатура и поле ввода**
Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима. Исправлено ложное срабатывание многострочного режима.
Сообщения поднимаются синхронно с клавиатурой. Исправлено перекрытие сообщений при закрытии. Скругление поля ввода корректно возвращается к капсуле при переходе из многострочного режима.
**Интерфейс чата**
Тёмные градиенты по краям экрана — контент плавно уходит под навбар и home indicator. Аватарки в списке чатов обновляются мгновенно без перезахода. На iOS 26 поле ввода корректно внизу экрана, клавиатура работает нативно.
**Доставка сообщений**
Сообщения больше не помечаются ошибкой при кратковременном обрыве — показываются часики и автодоставка при реконнекте. Свежий timestamp при повторной отправке — сервер больше не отклоняет. Быстрый реконнект на foreground без 3-секундной задержки.
**Синхронизация**
Прочтения от оппонента больше не теряются при синке — переприменяются после вставки сообщений. Схема БД приведена к Android/Desktop паритету (delivered + read). Прочтение больше не перезаписывает ошибочные сообщения.
**Доставка и синхронизация**
Сообщения больше не помечаются ошибкой при кратковременном обрыве — часики и автодоставка при реконнекте. Прочтения от оппонента корректно синхронизируются.
"""
)
]

View File

@@ -1,64 +1,102 @@
import SwiftUI
import UIKit
/// Wraps SwiftUI content in a UIKit container whose vertical position is animated
/// using the keyboard's exact Core Animation curve. This achieves Telegram-level
/// keyboard sync because both the keyboard and this container are animated by the
/// render server in the same Core Animation transaction zero relative movement.
/// Telegram-style keyboard synchronization: list and composer are TWO independent
/// UIHostingControllers within a single UIViewController.
///
/// On iOS 26+, SwiftUI handles keyboard natively this wrapper is a no-op passthrough.
struct KeyboardSyncedContainer<Content: View>: View {
/// - 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) {
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)
.ignoresSafeArea(.keyboard)
_KeyboardSyncedRepresentable(
content: content,
composer: composer,
onComposerHeightChange: onComposerHeightChange,
onTopSafeAreaChange: onTopSafeAreaChange
)
.ignoresSafeArea()
}
}
}
// MARK: - UIViewControllerRepresentable bridge
private struct _KeyboardSyncedRepresentable<Content: View>: UIViewControllerRepresentable {
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> {
_KeyboardSyncedVC(rootView: content)
}
func updateUIViewController(_ vc: _KeyboardSyncedVC<Content>, context: Context) {
vc.hostingController.rootView = content
}
func sizeThatFits(
_ proposal: ProposedViewSize,
uiViewController vc: _KeyboardSyncedVC<Content>,
func makeUIViewController(
context: Context
) -> CGSize? {
let width = proposal.width ?? UIScreen.main.bounds.width
let fittingSize = vc.hostingController.sizeThatFits(
in: CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
)
return CGSize(width: width, height: fittingSize.height)
) -> _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 that animates with the keyboard
// MARK: - UIKit view controller: two hosting controllers
final class _KeyboardSyncedVC<Content: View>: UIViewController {
final class _KeyboardSyncedVC<Content: View, Composer: View>: UIViewController, UIGestureRecognizerDelegate {
let hostingController: UIHostingController<Content>
private var bottomInset: CGFloat = 34
let listController: UIHostingController<Content>
let composerController: UIHostingController<Composer>
init(rootView: Content) {
hostingController = UIHostingController(rootView: rootView)
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)
}
@@ -69,56 +107,105 @@ final class _KeyboardSyncedVC<Content: View>: UIViewController {
super.viewDidLoad()
view.backgroundColor = .clear
addChild(hostingController)
hostingController.view.backgroundColor = .clear
view.addSubview(hostingController.view)
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
hostingController.didMove(toParent: self)
// Read safe area bottom inset
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.keyWindow ?? scene.windows.first {
let bottom = window.safeAreaInsets.bottom
bottomInset = bottom < 50 ? bottom : 34
// 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 = []
}
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillChangeFrame),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil
// 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 keyboardWillChangeFrame(_ notification: Notification) {
guard let info = notification.userInfo,
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
else { return }
let screenHeight = UIScreen.main.bounds.height
let keyboardTop = endFrame.origin.y
let isVisible = keyboardTop < screenHeight
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
let padding = isVisible ? max(0, endHeight - bottomInset) : 0
// Animate with the KEYBOARD'S EXACT curve in the SAME Core Animation transaction.
// The render server interpolates both the keyboard position and our transform
// together for each frame pixel-perfect sync, zero gap variation.
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState]) {
self.view.transform = CGAffineTransform(translationX: 0, y: -padding)
@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)
}
}
deinit {
NotificationCenter.default.removeObserver(self)
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
true
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// Bridge: UIKit measures exact nav bar height SwiftUI applies via .safeAreaInset.
// No additionalSafeAreaInsets (negative values break UIScrollView math).
let navBarHeight = view.safeAreaInsets.top
if abs(navBarHeight - lastReportedTopSafeArea) > 1 {
lastReportedTopSafeArea = navBarHeight
DispatchQueue.main.async { [weak self] in
self?.onTopSafeAreaChange?(navBarHeight)
}
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
let height = composerController.view.bounds.height
if height > 0, abs(height - lastReportedComposerHeight) > 1 {
lastReportedComposerHeight = height
DispatchQueue.main.async { [weak self] in
self?.onComposerHeightChange?(height)
}
}
}
}

View File

@@ -79,21 +79,24 @@ final class KeyboardTracker: ObservableObject {
bottomInset = 34
}
// iOS 26+: SwiftUI handles keyboard natively no tracking needed.
if #available(iOS 26, *) { return }
// KeyboardSyncedContainer handles keyboard via keyboardLayoutGuide (iOS < 26).
// SwiftUI handles keyboard natively (iOS 26+).
// No notification/CADisplayLink/KVO tracking needed for either version.
// Legacy code below disabled kept for reference.
if false {
// iOS < 26: sync view + CADisplayLink reads keyboard's REAL position
// each frame from the same CA transaction. Pixel-perfect sync.
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables)
// iOS < 26: sync view + CADisplayLink reads keyboard's REAL position
// each frame from the same CA transaction. Pixel-perfect sync.
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables)
// Pre-create display link (paused) avoids allocation overhead on first keyboard show.
displayLinkProxy = DisplayLinkProxy { [weak self] in
self?.animationTick()
// Pre-create display link (paused) avoids allocation overhead on first keyboard show.
displayLinkProxy = DisplayLinkProxy { [weak self] in
self?.animationTick()
}
displayLinkProxy?.isPaused = true
}
displayLinkProxy?.isPaused = true
}
/// Sets keyboardPadding with animation matching keyboard duration.

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,10 @@ final class ChatDetailViewModel: ObservableObject {
@Published private(set) var isTyping: Bool = false
/// Android parity: true while loading messages from DB. Shows skeleton placeholder.
@Published private(set) var isLoading: Bool = true
/// Pagination: true while older messages are available in SQLite.
@Published private(set) var hasMoreMessages: Bool = true
/// Pagination: guard against concurrent loads.
@Published private(set) var isLoadingMore: Bool = false
private var cancellables = Set<AnyCancellable>()
@@ -29,6 +33,10 @@ final class ChatDetailViewModel: ObservableObject {
// Android parity: if we already have messages, skip skeleton.
// Otherwise keep isLoading=true until first Combine emission or timeout.
isLoading = initial.isEmpty
// If initial load returned fewer than pageSize, no more to load.
if initial.count < MessageRepository.pageSize {
hasMoreMessages = false
}
// Subscribe to messagesByDialog changes, filtered to our dialog only.
// Broken into steps to help the Swift type-checker.
@@ -43,7 +51,9 @@ final class ChatDetailViewModel: ObservableObject {
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
guard lhs.count == rhs.count else { return false }
for i in lhs.indices {
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
if lhs[i].id != rhs[i].id ||
lhs[i].deliveryStatus != rhs[i].deliveryStatus ||
lhs[i].isRead != rhs[i].isRead {
return false
}
}
@@ -74,4 +84,23 @@ final class ChatDetailViewModel: ObservableObject {
}
.store(in: &cancellables)
}
/// Pagination: load older messages from SQLite when user scrolls to top.
func loadMore() async {
guard !isLoadingMore, hasMoreMessages else { return }
guard let earliest = messages.first else { return }
isLoadingMore = true
let older = MessageRepository.shared.loadOlderMessages(
for: dialogKey,
beforeTimestamp: earliest.timestamp,
limit: MessageRepository.pageSize
)
if older.count < MessageRepository.pageSize {
hasMoreMessages = false
}
// messages will update via Combine pipeline (repo already prepends to cache).
isLoadingMore = false
}
}

View File

@@ -0,0 +1,16 @@
import Foundation
/// Stable callback reference for message cell interactions.
/// Class ref means SwiftUI sees the same pointer on parent re-render,
/// so cells are NOT marked dirty due to closure diffing (memcmp).
@MainActor
final class MessageCellActions {
var onReply: (ChatMessage) -> Void = { _ in }
var onForward: (ChatMessage) -> Void = { _ in }
var onDelete: (ChatMessage) -> Void = { _ in }
var onCopy: (String) -> Void = { _ in }
var onImageTap: (String) -> Void = { _ in }
var onScrollToMessage: (String) -> Void = { _ in }
var onRetry: (ChatMessage) -> Void = { _ in }
var onRemove: (ChatMessage) -> Void = { _ in }
}

View File

@@ -0,0 +1,843 @@
import SwiftUI
import UIKit
/// Equatable message cell SwiftUI skips body re-evaluation when inputs haven't changed.
/// Extracted from ChatDetailView to create an Equatable boundary for `.equatable()` modifier.
struct MessageCellView: View, Equatable {
let message: ChatMessage
let maxBubbleWidth: CGFloat
let position: BubblePosition
let currentPublicKey: String
let highlightedMessageId: String?
let isSavedMessages: Bool
let isSystemAccount: Bool
let opponentPublicKey: String
let opponentTitle: String
let opponentUsername: String
let actions: MessageCellActions
static func == (lhs: MessageCellView, rhs: MessageCellView) -> Bool {
lhs.message == rhs.message &&
lhs.maxBubbleWidth == rhs.maxBubbleWidth &&
lhs.position == rhs.position &&
lhs.highlightedMessageId == rhs.highlightedMessageId
// currentPublicKey, isSavedMessages, isSystemAccount, opponent* stable per chat session
// actions class ref, excluded (pointer identity is unstable across re-renders)
}
var body: some View {
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
let hasTail = position == .single || position == .bottom
let visibleAttachments = message.attachments.filter {
$0.type == .image || $0.type == .file || $0.type == .avatar
}
Group {
if visibleAttachments.isEmpty {
let hasReplyAttachment = message.attachments.contains(where: { $0.type == .messages })
if hasReplyAttachment || !Self.isGarbageText(message.text) {
textOnlyBubble(
message: message,
outgoing: outgoing,
hasTail: hasTail,
maxBubbleWidth: maxBubbleWidth,
position: position
)
}
} else {
attachmentBubble(
message: message,
attachments: visibleAttachments,
outgoing: outgoing,
hasTail: hasTail,
maxBubbleWidth: maxBubbleWidth,
position: position
)
}
}
.modifier(ConditionalSwipeToReply(
enabled: !isSavedMessages && !isSystemAccount,
onReply: { actions.onReply(message) }
))
.overlay {
if highlightedMessageId == message.id {
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.12))
.allowsHitTesting(false)
}
}
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
.padding(.top, (position == .single || position == .top) ? 6 : 2)
.padding(.bottom, 0)
}
// MARK: - Text-Only Bubble
@ViewBuilder
private func textOnlyBubble(
message: ChatMessage, outgoing: Bool, hasTail: Bool,
maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let messageText = message.text.isEmpty ? " " : message.text
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
let replyData = replyAttachment.flatMap {
parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview)
}?.first
let isForward = (message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| Self.isGarbageText(message.text)) && replyData != nil
if isForward, let reply = replyData {
forwardedMessageBubble(
message: message, reply: reply, outgoing: outgoing,
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
)
} else {
VStack(alignment: .leading, spacing: 0) {
if let reply = replyData {
replyQuoteView(reply: reply, outgoing: outgoing)
}
Text(parsedMarkdown(messageText))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.vertical, 5)
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
timestampOverlay(message: message, outgoing: outgoing)
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
replyQuoteHeight: replyData != nil ? 46 : 0,
onReplyQuoteTap: replyData.map { reply in
{ [reply] in actions.onScrollToMessage(reply.message_id) }
}
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
}
// MARK: - Forwarded Message Bubble
@ViewBuilder
private func forwardedMessageBubble(
message: ChatMessage, reply: ReplyMessageData, outgoing: Bool,
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let senderName = senderDisplayName(for: reply.publicKey)
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
let imageAttachments = reply.attachments.filter { $0.type == 0 }
let fileAttachments = reply.attachments.filter { $0.type == 2 }
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
&& !Self.isGarbageText(reply.message)
#if DEBUG
let _ = {
if reply.attachments.isEmpty {
print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))")
if let att = message.attachments.first(where: { $0.type == .messages }) {
let blobPrefix = att.blob.prefix(60)
let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[")
print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)")
}
} else {
print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)")
}
}()
#endif
let fallbackText: String = {
if hasCaption { return reply.message }
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
if let file = fileAttachments.first {
let parts = file.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
return file.id.isEmpty ? "File" : file.id
}
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
return "Message"
}()
let imageContentWidth = maxBubbleWidth - 22
- (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
- (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
VStack(alignment: .leading, spacing: 0) {
Text("Forwarded from")
.font(.system(size: 13, weight: .regular))
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.top, 6)
HStack(spacing: 6) {
AvatarView(
initials: senderInitials,
colorIndex: senderColorIndex,
size: 20,
image: senderAvatar
)
Text(senderName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
.lineLimit(1)
}
.padding(.leading, 11)
.padding(.top, 3)
if !imageAttachments.isEmpty {
ForwardedPhotoCollageView(
attachments: imageAttachments,
outgoing: outgoing,
maxWidth: imageContentWidth,
onImageTap: { attId in actions.onImageTap(attId) }
)
.padding(.horizontal, 6)
.padding(.top, 4)
}
ForEach(fileAttachments, id: \.id) { att in
forwardedFilePreview(attachment: att, outgoing: outgoing)
.padding(.horizontal, 6)
.padding(.top, 4)
}
if hasCaption {
Text(parsedMarkdown(reply.message))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 3)
.padding(.bottom, 5)
} else if !hasVisualAttachments {
Text(fallbackText)
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 3)
.padding(.bottom, 5)
} else {
Spacer().frame(height: 5)
}
}
.frame(minWidth: 130, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
if !imageAttachments.isEmpty && !hasCaption {
mediaTimestampOverlay(message: message, outgoing: outgoing)
} else {
timestampOverlay(message: message, outgoing: outgoing)
}
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !imageAttachments.isEmpty ? { _ in
if let firstId = imageAttachments.first?.id {
actions.onImageTap(firstId)
}
} : nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
// MARK: - Attachment Bubble
@ViewBuilder
private func attachmentBubble(
message: ChatMessage, attachments: [MessageAttachment], outgoing: Bool,
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
) -> some View {
let hasCaption = Self.isValidCaption(message.text)
let partitioned = Self.partitionAttachments(attachments)
let imageAttachments = partitioned.images
let otherAttachments = partitioned.others
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
VStack(alignment: .leading, spacing: 0) {
if !imageAttachments.isEmpty {
PhotoCollageView(
attachments: imageAttachments,
message: message,
outgoing: outgoing,
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
position: position
)
}
ForEach(otherAttachments, id: \.id) { attachment in
Group {
switch attachment.type {
case .file:
MessageFileView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 4)
.padding(.top, 4)
case .avatar:
MessageAvatarView(
attachment: attachment,
message: message,
outgoing: outgoing
)
.padding(.horizontal, 6)
.padding(.top, 4)
default:
EmptyView()
}
}
}
if hasCaption {
Text(parsedMarkdown(message.text))
.font(.system(size: 17, weight: .regular))
.tracking(-0.43)
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.multilineTextAlignment(.leading)
.lineSpacing(0)
.fixedSize(horizontal: false, vertical: true)
.padding(.leading, 11)
.padding(.trailing, outgoing ? 64 : 48)
.padding(.top, 6)
.padding(.bottom, 5)
}
}
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
.overlay(alignment: .bottomTrailing) {
if isImageOnly {
mediaTimestampOverlay(message: message, outgoing: outgoing)
} else {
timestampOverlay(message: message, outgoing: outgoing)
}
}
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
.background { bubbleBackground(outgoing: outgoing, position: position) }
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
.overlay {
BubbleContextMenuOverlay(
actions: bubbleActions(for: message),
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
readStatusText: contextMenuReadStatus(for: message),
onTap: !attachments.isEmpty ? { tapLocation in
if !imageAttachments.isEmpty {
let tappedId = imageAttachments.count == 1
? imageAttachments[0].id
: Self.collageAttachmentId(
at: tapLocation,
attachments: imageAttachments,
maxWidth: maxBubbleWidth
)
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
actions.onImageTap(tappedId)
} else {
NotificationCenter.default.post(
name: .triggerAttachmentDownload, object: tappedId
)
}
} else {
for att in otherAttachments {
NotificationCenter.default.post(
name: .triggerAttachmentDownload, object: att.id
)
}
}
} : nil
)
}
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
}
// MARK: - Timestamp Overlays
@ViewBuilder
private func timestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(
outgoing
? Color.white.opacity(0.55)
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
deliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
.padding(.trailing, 11)
.padding(.bottom, 5)
}
@ViewBuilder
private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
HStack(spacing: 3) {
Text(messageTime(message.timestamp))
.font(.system(size: 11, weight: .regular))
.foregroundStyle(.white)
if outgoing {
if message.deliveryStatus == .error {
errorMenu(for: message)
} else {
mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
}
}
}
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(Color.black.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: 6))
.padding(.trailing, 6)
.padding(.bottom, 6)
}
// MARK: - Delivery Indicators
private func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color {
if status == .delivered && read { return Color(hex: 0xA4E2FF) }
switch status {
case .delivered: return Color.white.opacity(0.5)
case .error: return RosettaColors.error
default: return Color.white.opacity(0.78)
}
}
@ViewBuilder
private func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
if status == .delivered && read {
DoubleCheckmarkShape()
.fill(deliveryTint(status, read: read))
.frame(width: 16, height: 8.7)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(deliveryTint(status, read: read))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status, read: read))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(deliveryTint(status, read: read))
}
}
}
@ViewBuilder
private func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
if status == .delivered && read {
DoubleCheckmarkShape()
.fill(Color.white)
.frame(width: 16, height: 8.7)
} else {
switch status {
case .delivered:
SingleCheckmarkShape()
.fill(Color.white.opacity(0.8))
.frame(width: 12, height: 8.8)
case .waiting:
Image(systemName: "clock")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.white.opacity(0.8))
case .error:
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
}
@ViewBuilder
private func errorMenu(for message: ChatMessage) -> some View {
Menu {
Button {
actions.onRetry(message)
} label: {
Label("Retry", systemImage: "arrow.clockwise")
}
Button(role: .destructive) {
actions.onRemove(message)
} label: {
Label("Remove", systemImage: "trash")
}
} label: {
Image(systemName: "exclamationmark.circle.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(RosettaColors.error)
}
}
// MARK: - Bubble Background
private var incomingBubbleFill: Color {
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
}
@ViewBuilder
private func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View {
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
MessageBubbleShape(position: position, outgoing: outgoing)
.fill(fill)
}
// MARK: - Context Menu
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
return "Read"
}
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
var result: [BubbleContextAction] = []
result.append(BubbleContextAction(
title: "Reply",
image: UIImage(systemName: "arrowshape.turn.up.left"),
role: []
) { actions.onReply(message) })
result.append(BubbleContextAction(
title: "Copy",
image: UIImage(systemName: "doc.on.doc"),
role: []
) { actions.onCopy(message.text) })
if !message.attachments.contains(where: { $0.type == .avatar }) {
result.append(BubbleContextAction(
title: "Forward",
image: UIImage(systemName: "arrowshape.turn.up.right"),
role: []
) { actions.onForward(message) })
}
result.append(BubbleContextAction(
title: "Delete",
image: UIImage(systemName: "trash"),
role: .destructive
) { actions.onDelete(message) })
return result
}
// MARK: - Reply Quote
@ViewBuilder
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
let senderName = senderDisplayName(for: reply.publicKey)
let previewText: String = {
let trimmed = reply.message.trimmingCharacters(in: .whitespaces)
if !trimmed.isEmpty { return reply.message }
if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" }
if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" }
if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" }
if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
return "Attachment"
}()
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
let blurHash: String? = {
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
let parts = att.preview.components(separatedBy: "::")
let hash = parts.count > 1 ? parts[1] : att.preview
return hash.isEmpty ? nil : hash
}()
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1.5)
.fill(accentColor)
.frame(width: 3)
.padding(.vertical, 4)
if let att = imageAttachment {
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
.padding(.leading, 6)
}
VStack(alignment: .leading, spacing: 1) {
Text(senderName)
.font(.system(size: 15, weight: .semibold))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
.lineLimit(1)
Text(previewText)
.font(.system(size: 15, weight: .regular))
.tracking(-0.23)
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
.lineLimit(1)
}
.padding(.leading, 6)
Spacer(minLength: 0)
}
.frame(height: 41)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
.padding(.horizontal, 5)
.padding(.top, 5)
.padding(.bottom, 0)
}
// MARK: - Forwarded File Preview
@ViewBuilder
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
let filename: String = {
let parts = attachment.preview.components(separatedBy: "::")
if parts.count > 2 { return parts[2] }
return attachment.id.isEmpty ? "File" : attachment.id
}()
HStack(spacing: 8) {
Image(systemName: "doc.fill")
.font(.system(size: 20))
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
Text(filename)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
.lineLimit(1)
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
)
}
// MARK: - Collage Hit Test
static func collageAttachmentId(
at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat
) -> String {
let spacing: CGFloat = 2
let count = attachments.count
let x = point.x
let y = point.y
switch count {
case 2:
let half = (maxWidth - spacing) / 2
return attachments[x < half ? 0 : 1].id
case 3:
let rightWidth = maxWidth * 0.34
let leftWidth = maxWidth - spacing - rightWidth
let totalHeight = min(leftWidth * 1.1, 300)
let rightCellHeight = (totalHeight - spacing) / 2
if x < leftWidth {
return attachments[0].id
} else {
return attachments[y < rightCellHeight ? 1 : 2].id
}
case 4:
let half = (maxWidth - spacing) / 2
let cellHeight = min(half * 0.85, 150)
let row = y < cellHeight ? 0 : 1
let col = x < half ? 0 : 1
return attachments[row * 2 + col].id
case 5:
let topCellWidth = (maxWidth - spacing) / 2
let bottomCellWidth = (maxWidth - spacing * 2) / 3
let topHeight = min(topCellWidth * 0.85, 165)
if y < topHeight {
return attachments[x < topCellWidth ? 0 : 1].id
} else {
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
return attachments[2 + col].id
}
default:
return attachments[0].id
}
}
// MARK: - Sender Display Name
@MainActor private static var senderNameCache: [String: String] = [:]
private func senderDisplayName(for publicKey: String) -> String {
if publicKey == currentPublicKey {
return "You"
}
if let cached = Self.senderNameCache[publicKey] {
return cached
}
if publicKey == opponentPublicKey && !opponentTitle.isEmpty {
Self.senderNameCache[publicKey] = opponentTitle
return opponentTitle
}
if let dialog = DialogRepository.shared.dialogs[publicKey],
!dialog.opponentTitle.isEmpty {
Self.senderNameCache[publicKey] = dialog.opponentTitle
return dialog.opponentTitle
}
if publicKey == opponentPublicKey && !opponentUsername.isEmpty {
let name = "@\(opponentUsername)"
Self.senderNameCache[publicKey] = name
return name
}
return String(publicKey.prefix(8)) + ""
}
// MARK: - Static Caches
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
guard !blob.isEmpty else { return nil }
if let cached = Self.replyBlobCache[blob] { return cached }
guard let data = blob.data(using: .utf8) else { return nil }
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
if Self.replyBlobCache.count > 300 {
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
}
Self.replyBlobCache[blob] = result
return result
}
@MainActor private static var markdownCache: [String: AttributedString] = [:]
private func parsedMarkdown(_ text: String) -> AttributedString {
if let cached = Self.markdownCache[text] {
PerformanceLogger.shared.track("markdown.cacheHit")
return cached
}
PerformanceLogger.shared.track("markdown.cacheMiss")
let withEmoji = EmojiParser.replaceShortcodes(in: text)
let result: AttributedString
if let parsed = try? AttributedString(
markdown: withEmoji,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
) {
result = parsed
} else {
result = AttributedString(withEmoji)
}
if Self.markdownCache.count > 500 {
let keysToRemove = Array(Self.markdownCache.keys.prefix(250))
for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) }
}
Self.markdownCache[text] = result
return result
}
@MainActor private static var blurHashCache: [String: UIImage] = [:]
@MainActor
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
let key = "\(hash)_\(width)x\(height)"
if let cached = blurHashCache[key] { return cached }
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
if blurHashCache.count > 300 {
let keysToRemove = Array(blurHashCache.keys.prefix(150))
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
}
blurHashCache[key] = image
return image
}
@MainActor private static var timeCache: [Int64: String] = [:]
private func messageTime(_ timestamp: Int64) -> String {
if let cached = Self.timeCache[timestamp] { return cached }
let result = Self.timeFormatter.string(
from: Date(timeIntervalSince1970: Double(timestamp) / 1000)
)
if Self.timeCache.count > 500 {
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
}
Self.timeCache[timestamp] = result
return result
}
static let timeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "HH:mm"
return formatter
}()
// 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
}
static func isValidCaption(_ text: String) -> Bool {
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.isEmpty { return false }
if text == " " { return false }
if isGarbageText(text) { return false }
return true
}
static func partitionAttachments(
_ attachments: [MessageAttachment]
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
var images: [MessageAttachment] = []
var others: [MessageAttachment] = []
for att in attachments {
if att.type == .image { images.append(att) }
else { others.append(att) }
}
return (images, others)
}
}