From d0041f0c10b617f5489ccf5d38aef1f8b63e5b2e Mon Sep 17 00:00:00 2001 From: senseiGai Date: Wed, 25 Mar 2026 15:06:01 +0500 Subject: [PATCH] =?UTF-8?q?Equatable-=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9,=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=D0=B0,=20=D0=BE=D0=BF=D1=82?= =?UTF-8?q?=D0=B8=D0=BC=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20removeDuplic?= =?UTF-8?q?ates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 8 +- .../Data/Repositories/MessageRepository.swift | 6 +- Rosetta/Core/Utils/ReleaseNotes.swift | 18 +- .../Components/KeyboardSyncedContainer.swift | 239 ++- .../Components/KeyboardTracker.swift | 29 +- .../Chats/ChatDetail/ChatDetailView.swift | 1375 +++-------------- .../ChatDetail/ChatDetailViewModel.swift | 31 +- .../Chats/ChatDetail/MessageCellActions.swift | 16 + .../Chats/ChatDetail/MessageCellView.swift | 843 ++++++++++ 9 files changed, 1307 insertions(+), 1258 deletions(-) create mode 100644 Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift create mode 100644 Rosetta/Features/Chats/ChatDetail/MessageCellView.swift diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 019f449..3eb5f73 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -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 = ""; diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index ba64ab1..cc13844 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -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 } diff --git a/Rosetta/Core/Utils/ReleaseNotes.swift b/Rosetta/Core/Utils/ReleaseNotes.swift index f4c5d5d..f8a94e5 100644 --- a/Rosetta/Core/Utils/ReleaseNotes.swift +++ b/Rosetta/Core/Utils/ReleaseNotes.swift @@ -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). Прочтение больше не перезаписывает ошибочные сообщения. + **Доставка и синхронизация** + Сообщения больше не помечаются ошибкой при кратковременном обрыве — часики и автодоставка при реконнекте. Прочтения от оппонента корректно синхронизируются. """ ) ] diff --git a/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift b/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift index 2dc8492..dfeb768 100644 --- a/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift +++ b/Rosetta/DesignSystem/Components/KeyboardSyncedContainer.swift @@ -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: 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: 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: 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 { - _KeyboardSyncedVC(rootView: content) - } - - func updateUIViewController(_ vc: _KeyboardSyncedVC, context: Context) { - vc.hostingController.rootView = content - } - - func sizeThatFits( - _ proposal: ProposedViewSize, - uiViewController vc: _KeyboardSyncedVC, + 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 { + let vc = _KeyboardSyncedVC(content: content, composer: composer) + vc.onComposerHeightChange = onComposerHeightChange + vc.onTopSafeAreaChange = onTopSafeAreaChange + return vc } + + func updateUIViewController( + _ vc: _KeyboardSyncedVC, + context: Context + ) { + vc.listController.rootView = content + vc.composerController.rootView = composer + vc.onComposerHeightChange = onComposerHeightChange + vc.onTopSafeAreaChange = onTopSafeAreaChange + } + + // No sizeThatFits — container fills all proposed space from parent. } -// MARK: - UIKit view controller that animates with the keyboard +// MARK: - UIKit view controller: two hosting controllers -final class _KeyboardSyncedVC: UIViewController { +final class _KeyboardSyncedVC: UIViewController, UIGestureRecognizerDelegate { - let hostingController: UIHostingController - private var bottomInset: CGFloat = 34 + let listController: UIHostingController + let composerController: UIHostingController - 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: 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) + } + } } } diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index 7a621ad..8a46006 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -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. diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 5908236..4818720 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -10,69 +10,19 @@ private struct ComposerHeightKey: PreferenceKey { } } -/// Reserves space at the bottom of the scroll content for the composer + keyboard. -/// iOS < 26: inputAccessoryView handles composer, spacer includes keyboard height -/// so messages stay above the keyboard. keyboardPadding from notification (instant). -/// iOS 26+: SwiftUI handles keyboard natively, spacer only for composer overlay. +/// 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 { - @ObservedObject private var keyboard = KeyboardTracker.shared let composerHeight: CGFloat var body: some View { - let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval") - let height: CGFloat = { - if #available(iOS 26, *) { - return composerHeight - } else { - // Inverted scroll: spacer at VStack START. Growing it pushes - // messages away from offset=0 → visually UP. CADisplayLink - // animates keyboardPadding in sync with keyboard curve. - return composerHeight + keyboard.spacerPadding + 8 - } - }() - #if DEBUG - let _ = { print("📏 Spacer | h=\(Int(height)) kbPad=\(Int(keyboard.keyboardPadding)) compH=\(Int(composerHeight))") }() - #endif - Color.clear.frame(height: max(height, 0)) + Color.clear.frame(height: max(composerHeight, 0)) } } -/// Applies keyboard bottom padding in an isolated observation scope. -/// Parent view is NOT marked dirty when keyboardPadding changes. -private struct KeyboardPaddedView: View { - @ObservedObject private var keyboard = KeyboardTracker.shared - let extraPadding: CGFloat - let content: Content - - init(extraPadding: CGFloat = 0, @ViewBuilder content: () -> Content) { - self.extraPadding = extraPadding - self.content = content() - } - - var body: some View { - let _ = PerformanceLogger.shared.track("keyboardPadded.bodyEval") - content.offset(y: -(keyboard.keyboardPadding + extraPadding)) - } -} - -/// Shifts empty state content up by half the keyboard height to keep it -/// centered in the visible area above keyboard. Uses `.offset` (visual-only) -/// instead of frame height changes that would leak layout to the compositor overlay. -/// Observation-isolated: keyboard changes re-render only this wrapper. -private struct EmptyStateKeyboardOffset: View { - @ObservedObject private var keyboard = KeyboardTracker.shared - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - // keyboardPadding is 0 (KeyboardTracker is inert for both iOS versions). - // Empty state doesn't need keyboard offset — composer handles positioning. - content - } -} struct ChatDetailView: View { let route: ChatRoute @@ -95,6 +45,9 @@ 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? @@ -114,6 +67,10 @@ struct ChatDetailView: View { /// ID of message currently highlighted after scroll-to-reply navigation. @State private var highlightedMessageId: String? + /// Stable callback reference for message cell interactions. + /// Class ref pointer is stable across parent re-renders → cells not marked dirty. + @State private var cellActions = MessageCellActions() + /// Cached at view init — never changes during a session. Avoids @Observable /// observation on SessionManager that would re-render all cells on any state change. private let currentPublicKey: String = SessionManager.shared.currentPublicKey @@ -185,47 +142,101 @@ struct ChatDetailView: View { 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. @ViewBuilder - private var content: some View { - let _ = PerformanceLogger.shared.track("chatDetail.bodyEval") + private var chatArea: some View { ZStack { messagesList(maxBubbleWidth: maxBubbleWidth) } - .overlay { chatEdgeGradients } + .overlay { + chatEdgeGradients + .modifier(CounterUIKitFlipModifier()) + } // FPS overlay — uncomment for performance testing: // .overlay { FPSOverlayView() } - // Composer overlay — always visible, no becomeFirstResponder delay. - // iOS < 26: offset by keyboardPadding (from notification + withAnimation). - // iOS 26+: SwiftUI handles keyboard natively (keyboardPadding = 0). - .overlay { - if !route.isSystemAccount { - if #available(iOS 26, *) { - ComposerOverlay(composer: composer, composerHeight: $composerHeight) - } else { - ComposerUIKitContainer(content: composer) { height in - if abs(height - composerHeight) > 1 { composerHeight = height } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - } - .onPreferenceChange(ComposerHeightKey.self) { newHeight in - composerHeight = newHeight - } - .modifier(IgnoreKeyboardSafeAreaLegacy()) .background { ZStack { RosettaColors.Adaptive.background tiledChatBackground + .modifier(CounterUIKitFlipModifier()) } .ignoresSafeArea() } + } + + @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. + Group { + if #available(iOS 26, *) { + chatArea + .overlay { + if !route.isSystemAccount { + ComposerOverlay( + composer: composer, + composerHeight: $composerHeight + ) + } + } + .onPreferenceChange(ComposerHeightKey.self) { newHeight in + composerHeight = newHeight + } + } else { + KeyboardSyncedContainer( + content: { chatArea }, + composer: { + if !route.isSystemAccount { composer } + }, + onComposerHeightChange: { height in + composerHeight = height + }, + onTopSafeAreaChange: { inset in + topSafeArea = inset + } + ) + } + } .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) .enableSwipeBack() @@ -234,6 +245,15 @@ struct ChatDetailView: View { .toolbar(.hidden, for: .tabBar) .task { isViewActive = true + // Wire up cell action callbacks (once, stable class ref). + cellActions.onReply = { [self] msg in replyingToMessage = msg; isInputFocused = true } + cellActions.onForward = { [self] msg in forwardingMessage = msg; showForwardPicker = true } + cellActions.onDelete = { [self] msg in messageToDelete = msg } + cellActions.onCopy = { text in UIPasteboard.general.string = text } + cellActions.onImageTap = { [self] attId in openImageViewer(attachmentId: attId) } + cellActions.onScrollToMessage = { [self] msgId in scrollToMessageId = msgId } + cellActions.onRetry = { [self] msg in retryMessage(msg) } + cellActions.onRemove = { [self] msg in removeMessage(msg) } // Capture first unread incoming message BEFORE marking as read. if firstUnreadMessageId == nil { firstUnreadMessageId = messages.first(where: { @@ -571,9 +591,7 @@ private extension ChatDetailView { AvatarRepository.shared.loadAvatar(publicKey: route.publicKey) } - var incomingBubbleFill: Color { - RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E)) - } + // MARK: - Edge Gradients (Telegram-style) @@ -692,52 +710,47 @@ private extension ChatDetailView { } private var emptyStateView: some View { - // EmptyStateKeyboardOffset applies offset(y: -keyboardPadding/2) which is - // visual-only — does NOT affect layout. Keeps content centered in the visible - // area above keyboard without leaking layout changes to the compositor overlay. - EmptyStateKeyboardOffset { - VStack(spacing: 0) { - Spacer() + VStack(spacing: 0) { + Spacer() - VStack(spacing: 16) { - AvatarView( - initials: avatarInitials, - colorIndex: avatarColorIndex, - size: 80, - isOnline: dialog?.isOnline ?? false, - isSavedMessages: route.isSavedMessages, - image: opponentAvatar - ) + VStack(spacing: 16) { + AvatarView( + initials: avatarInitials, + colorIndex: avatarColorIndex, + size: 80, + isOnline: dialog?.isOnline ?? false, + isSavedMessages: route.isSavedMessages, + image: opponentAvatar + ) - VStack(spacing: 4) { - Text(titleText) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(RosettaColors.Adaptive.text) + VStack(spacing: 4) { + Text(titleText) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(RosettaColors.Adaptive.text) - if !route.isSavedMessages { - Text(subtitleText) - .font(.system(size: 14, weight: .regular)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary) - } + if !route.isSavedMessages { + Text(subtitleText) + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary) } - - Text(route.isSavedMessages - ? "Save messages here for quick access" - : "No messages yet") - .font(.system(size: 15)) - .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) - .padding(.top, 4) } - Spacer() - - // Reserve space for compositor so content centers above it. - Color.clear.frame(height: composerHeight) + Text(route.isSavedMessages + ? "Save messages here for quick access" + : "No messages yet") + .font(.system(size: 15)) + .foregroundStyle(RosettaColors.Adaptive.textSecondary.opacity(0.7)) + .padding(.top, 4) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .contentShape(Rectangle()) - .onTapGesture { isInputFocused = false } + + Spacer() + + // Reserve space for compositor so content centers above it. + Color.clear.frame(height: composerHeight) } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .onTapGesture { isInputFocused = false } } @ViewBuilder @@ -775,11 +788,20 @@ private extension ChatDetailView { VStack(spacing: 0) { let index = messageIndex(for: message.id) let position = bubblePosition(for: index) - messageRow( - message, + MessageCellView( + message: message, maxBubbleWidth: maxBubbleWidth, - position: position + 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). @@ -789,15 +811,32 @@ private extension ChatDetailView { } } } + + // PAGINATION TRIGGER (end of LazyVStack = visual top of chat). + // When sentinel appears, load older messages from SQLite. + if viewModel.hasMoreMessages { + ProgressView() + .frame(height: 40) + .frame(maxWidth: .infinity) + .scaleEffect(x: 1, y: -1) + .onAppear { + Task { await viewModel.loadMore() } + } + } } } .padding(.horizontal, 10) - .padding(.bottom, messagesTopInset) // visual top (near nav bar) + // 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()) - .scaleEffect(x: 1, y: -1) // INVERTED SCROLL — bottom-anchored by nature + // 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 { @@ -839,9 +878,10 @@ private extension ChatDetailView { // No keyboard scroll handlers needed — inverted scroll keeps bottom anchored. scroll .scrollIndicators(.hidden) - .overlay(alignment: .bottom) { + .overlay(alignment: scrollToBottomAlignment) { scrollToBottomButton(proxy: proxy) - .padding(.bottom, composerHeight + 4) + .modifier(CounterUIKitFlipModifier()) + .padding(scrollToBottomPaddingEdge, scrollToBottomPadding) } } } @@ -876,696 +916,13 @@ private extension ChatDetailView { .allowsHitTesting(!isAtBottom) } - @ViewBuilder - func messageRow(_ message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { - let _ = PerformanceLogger.shared.track("chatDetail.rowEval") - let outgoing = message.isFromMe(myPublicKey: currentPublicKey) - let hasTail = position == .single || position == .bottom + // 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. - // Desktop parity: render image, file, and avatar attachments in the bubble. - let visibleAttachments = message.attachments.filter { $0.type == .image || $0.type == .file || $0.type == .avatar } - - Group { - if visibleAttachments.isEmpty { - // Text-only message — skip if text is garbage/empty (avatar messages with failed decrypt). - // Exception: MESSAGES attachments (reply/forward) have empty text by design. - 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 { - // Attachment message: images/files + optional caption - attachmentBubble(message: message, attachments: visibleAttachments, outgoing: outgoing, hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position) - } - } - // Telegram-style swipe-to-reply: skip gesture entirely for system chats. - .modifier(ConditionalSwipeToReply( - enabled: !route.isSavedMessages && !route.isSystemAccount, - onReply: { - self.replyingToMessage = message - self.isInputFocused = true - } - )) - // Highlight overlay for scroll-to-reply navigation. - .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) - } - - /// Text-only message bubble (original design). - /// If the message has a MESSAGES attachment (reply/forward), shows the quoted message above text. - @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 }) - // Android parity: try blob first, fall back to preview (incoming may store in preview). - let replyData = replyAttachment.flatMap { parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview) }?.first - // Forward detection: text is empty/whitespace/garbage, but has a MESSAGES attachment with data. - // Uses isGarbageText to also catch replacement characters from encrypting empty text. - 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) { - // Reply quote (if present, not a forward) - if let reply = replyData { - replyQuoteView(reply: reply, outgoing: outgoing) - } - - // Telegram-style compact bubble: inline time+status at bottom-trailing. - 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 self.scrollToMessageId = reply.message_id } - } - ) - } - .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) - } - } - - // MARK: - Forwarded Message Bubble (Telegram-style) - - /// Renders a forwarded message with "Forwarded from" header, small avatar, sender name, - /// optional image/file previews, and the forwarded text as the main bubble content. - /// Android parity: `ForwardedMessagesBubble` + `ForwardedImagePreview` in ChatDetailComponents.kt. - @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) - - // Categorize forwarded attachments (inside the ReplyMessageData, NOT on message itself). - let imageAttachments = reply.attachments.filter { $0.type == 0 } - let fileAttachments = reply.attachments.filter { $0.type == 2 } - let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty - - // Text: show as caption below visual attachments, or as main content if no attachments. - // Filter garbage text (U+FFFD replacement chars from failed decryption of " " space text). - 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 - - // Fallback label when no visual attachments and no text. - let fallbackText: String = { - if hasCaption { return reply.message } - if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" } - if let file = fileAttachments.first { - // Parse preview for filename (format: "tag::fileSize::fileName") - 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) { - // "Forwarded from" label - 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) - - // Avatar + sender name - 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) - - // Forwarded image attachments — Telegram-style collage (same layout as PhotoCollageView). - if !imageAttachments.isEmpty { - ForwardedPhotoCollageView( - attachments: imageAttachments, - outgoing: outgoing, - maxWidth: imageContentWidth, - onImageTap: { attId in openImageViewer(attachmentId: attId) } - ) - .padding(.horizontal, 6) - .padding(.top, 4) - } - - // Forwarded file attachments. - ForEach(fileAttachments, id: \.id) { att in - forwardedFilePreview(attachment: att, outgoing: outgoing) - .padding(.horizontal, 6) - .padding(.top, 4) - } - - // Caption text (if original message had text) or fallback label. - 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 { - // No attachments and no text — show fallback. - 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 { - // Visual attachments shown but no caption — just add bottom padding for timestamp. - Spacer().frame(height: 5) - } - } - .frame(minWidth: 130, alignment: .leading) - .overlay(alignment: .bottomTrailing) { - if !imageAttachments.isEmpty && !hasCaption { - // Photo-only forward: dark pill overlay (same as regular photo messages) - 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 - // Open first forwarded image — user can swipe in gallery. - if let firstId = imageAttachments.first?.id { - openImageViewer(attachmentId: firstId) - } - } : nil - ) - } - .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) - } - - /// Wrapper that delegates to `ForwardedImagePreviewCell` — used by `forwardedFilePreview`. - @ViewBuilder - private func forwardedImagePreview(attachment: ReplyAttachmentData, width: CGFloat, outgoing: Bool) -> some View { - ForwardedImagePreviewCell( - attachment: attachment, - width: width, - fixedHeight: nil, - outgoing: outgoing, - onTapCachedImage: { openImageViewer(attachmentId: attachment.id) } - ) - } - - /// File attachment preview inside a forwarded message bubble. - /// Desktop parity: parse preview ("tag::fileSize::fileName") to extract filename. - @ViewBuilder - private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View { - let filename: String = { - // preview format: "tag::fileSize::fileName" (same as MessageFileView) - let parts = attachment.preview.components(separatedBy: "::") - if parts.count > 2 { return parts[2] } - // Fallback: try id, then generic label - 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)) - ) - } - - /// PERF: static cache for decoded reply blobs — avoids JSON decode on every re-render. - @MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:] - - /// Parses a decrypted MESSAGES blob into `ReplyMessageData` array. - 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 - } - - /// Telegram-style reply quote rendered above message text inside the bubble. - /// Matches Figma spec: 4px corners, 3px accent bar, 15pt font, semi-transparent bg. - /// Tapping scrolls to the original message and briefly highlights it. - @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 - // Check for image attachment to show thumbnail - 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 - }() - - // Tap is handled at UIKit level via BubbleContextMenuOverlay.onReplyQuoteTap. - HStack(spacing: 0) { - // 3px accent bar - RoundedRectangle(cornerRadius: 1.5) - .fill(accentColor) - .frame(width: 3) - .padding(.vertical, 4) - - // Optional image thumbnail for media replies (32×32). - // Uses ReplyQuoteThumbnail struct with @State + .task to check AttachmentCache - // first (shows actual image), falling back to blurhash if not cached. - 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) - } - - /// PERF: static cache for sender display names — avoids DialogRepository read per cell render. - /// DialogRepository is @Observable; reading `.dialogs[key]` in the body path creates observation - /// on the entire dictionary, causing re-render cascades on any dialog mutation. - @MainActor private static var senderNameCache: [String: String] = [:] - - /// Resolves a public key to a display name for reply/forward quotes. - /// Android parity: binary check (opponent → name, else → "You") + DialogRepository fallback. - /// Key prefix fallbacks are NOT cached — allows re-resolution when name arrives via sync/search. - private func senderDisplayName(for publicKey: String) -> String { - if publicKey == currentPublicKey { - return "You" - } - // PERF: cached lookup — avoids creating @Observable tracking on DialogRepository.dialogs - // in the per-cell render path. Cache is populated once per contact, valid for session. - if let cached = Self.senderNameCache[publicKey] { - return cached - } - // Current chat opponent — try route.title first (stable, non-observable). - if publicKey == route.publicKey && !route.title.isEmpty { - Self.senderNameCache[publicKey] = route.title - return route.title - } - // Live lookup from DialogRepository (only on cache miss, result is cached). - // Covers: opponent with empty route.title, and any other known contact. - if let dialog = DialogRepository.shared.dialogs[publicKey], - !dialog.opponentTitle.isEmpty { - Self.senderNameCache[publicKey] = dialog.opponentTitle - return dialog.opponentTitle - } - // Try username for current opponent. - if publicKey == route.publicKey && !route.username.isEmpty { - let name = "@\(route.username)" - Self.senderNameCache[publicKey] = name - return name - } - // Fallback: truncated key. NOT cached — re-resolution on next render when name arrives. - return String(publicKey.prefix(8)) + "…" - } - - /// PERF: single-pass partition of attachments into image vs non-image. - /// Avoids 3 separate .filter() calls per cell in @ViewBuilder context. - private 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) - } - - /// Check if text is a valid caption (not garbage, not just space). - /// Avatar messages have text=" ", which should NOT be shown as a caption. - /// Decryption failures may produce U+FFFD replacement characters. - private static func isValidCaption(_ text: String) -> Bool { - let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) - if cleaned.isEmpty { return false } - if text == " " { return false } - // Filter garbage: text containing ONLY replacement characters / control chars - if isGarbageText(text) { return false } - return true - } - - /// Detect garbage text from failed decryption — U+FFFD, control characters, null bytes. - /// Messages with only these characters should be hidden, not shown as text bubbles. - private static func isGarbageText(_ text: String) -> Bool { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return true } - // Check if ALL characters are garbage (replacement char, control, null) - let validCharacters = trimmed.unicodeScalars.filter { scalar in - scalar.value != 0xFFFD && // U+FFFD replacement character - scalar.value > 0x1F && // Control characters (0x00-0x1F) - scalar.value != 0x7F && // DEL - !CharacterSet.controlCharacters.contains(scalar) - } - return validCharacters.isEmpty - } - - /// Attachment message bubble: images/files with optional text caption. - /// - /// Telegram-style layout: - /// - **Image-only**: image fills bubble edge-to-edge, timestamp overlaid as dark pill - /// - **Image + text**: image at top, caption below, normal timestamp in caption area - /// - **File/Avatar**: padded inside bubble, normal timestamp - @ViewBuilder - private func attachmentBubble( - message: ChatMessage, - attachments: [MessageAttachment], - outgoing: Bool, - hasTail: Bool, - maxBubbleWidth: CGFloat, - position: BubblePosition - ) -> some View { - let hasCaption = Self.isValidCaption(message.text) - // PERF: single-pass partition instead of 3 separate .filter() calls per cell. - 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) { - // Image attachments — Telegram-style collage layout - if !imageAttachments.isEmpty { - PhotoCollageView( - attachments: imageAttachments, - message: message, - outgoing: outgoing, - maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0), - position: position - ) - } - - // Non-image attachments (file, avatar) — padded - // PERF: Group ensures 1 view per element → ForEach fast path. - 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() - } - } - } - - // Caption text below image - 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 { - // Telegram-style: dark pill overlay on image - 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 - // All taps go through the overlay (UIView blocks SwiftUI below). - // Route to the correct handler based on what was tapped. - if !imageAttachments.isEmpty { - let tappedId = imageAttachments.count == 1 - ? imageAttachments[0].id - : collageAttachmentId(at: tapLocation, attachments: imageAttachments, maxWidth: maxBubbleWidth) - if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil { - openImageViewer(attachmentId: tappedId) - } else { - // Image not cached — trigger download via notification. - NotificationCenter.default.post(name: .triggerAttachmentDownload, object: tappedId) - } - } else { - // No images — tap is on file/avatar area. - for att in otherAttachments { - NotificationCenter.default.post(name: .triggerAttachmentDownload, object: att.id) - } - } - } : nil - ) - } - .frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading) - } - - /// Timestamp + delivery status overlay for both text and attachment bubbles. - @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) - } - - /// Figma "Media=True" timestamp: dark semi-transparent pill overlaid on images. - @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: - BlurHash Cache - - /// PERF: static cache for decoded BlurHash images. Hash strings are immutable, - /// so results never need invalidation. Avoids DCT decode on every re-render. - @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 - } - - // MARK: - Text Parsing (Markdown + Emoji) - - /// Static cache for parsed markdown + emoji. Message text is immutable, - /// so results never need invalidation. Bounded at 200 entries (~5 chats). - /// Without cache, regex + markdown parser runs on EVERY body evaluation - /// for EVERY visible cell — expensive at 120Hz scroll. - @MainActor private static var markdownCache: [String: AttributedString] = [:] - - /// Parses inline markdown (`**bold**`) and emoji shortcodes (`:emoji_CODE:`) - /// from runtime strings. Emoji shortcodes are replaced BEFORE markdown parsing - /// so that emoji characters render inline with formatted text. - /// - /// Desktop parity: `TextParser.tsx` pattern `/:emoji_([a-zA-Z0-9_-]+):/` - /// Android parity: `unifiedToEmoji()` in `AppleEmojiPicker.kt` - private func parsedMarkdown(_ text: String) -> AttributedString { - if let cached = Self.markdownCache[text] { - PerformanceLogger.shared.track("markdown.cacheHit") - return cached - } - PerformanceLogger.shared.track("markdown.cacheMiss") - - // Cross-platform: replace :emoji_CODE: shortcodes with native Unicode emoji. - 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) - } - // PERF: evict oldest half instead of clearing all — preserves hot entries during scroll. - 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 - } // MARK: - Unread Separator @@ -1782,14 +1139,7 @@ private extension ChatDetailView { } } - // MARK: - Bubbles / Glass - - @ViewBuilder - func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View { - let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill - MessageBubbleShape(position: position, outgoing: outgoing) - .fill(fill) - } + // MARK: - Glass enum ChatGlassShape { case capsule @@ -1857,268 +1207,20 @@ private extension ChatDetailView { } } - /// Android/Desktop parity: delivery tint depends on both delivery status AND read flag. - 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) - } + /// Lightweight sender name resolution for image viewer — delegates to MessageCellView cache. + private func senderDisplayName(for publicKey: String) -> String { + if publicKey == currentPublicKey { return "You" } + if publicKey == route.publicKey && !route.title.isEmpty { return route.title } + if let dialog = DialogRepository.shared.dialogs[publicKey], + !dialog.opponentTitle.isEmpty { return dialog.opponentTitle } + if publicKey == route.publicKey && !route.username.isEmpty { return "@\(route.username)" } + return String(publicKey.prefix(8)) + "…" } - @ViewBuilder - 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)) - } - } - } - - /// Delivery indicator with white tint for on-image media overlay. - @ViewBuilder - 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 - func errorMenu(for message: ChatMessage) -> some View { - Menu { - Button { - retryMessage(message) - } label: { - Label("Retry", systemImage: "arrow.clockwise") - } - Button(role: .destructive) { - removeMessage(message) - } label: { - Label("Remove", systemImage: "trash") - } - } label: { - Image(systemName: "exclamationmark.circle.fill") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(RosettaColors.error) - } - } - - // MARK: - Context Menu - - /// Clean bubble preview for context menu — no `.frame(maxWidth: .infinity)`, no outer paddings. - @ViewBuilder - func bubblePreview(message: ChatMessage, maxBubbleWidth: CGFloat, position: BubblePosition) -> some View { - 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 } - - if visibleAttachments.isEmpty { - let messageText = message.text.isEmpty ? " " : message.text - 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) } - .contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing)) - .frame(maxWidth: maxBubbleWidth) - } else { - // Attachment preview — reuse full bubble, clip to shape - let hasCaption = Self.isValidCaption(message.text) - let imageAttachments = visibleAttachments.filter { $0.type == .image } - let otherAttachments = visibleAttachments.filter { $0.type != .image } - 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)) - .contentShape(.contextMenuPreview, MessageBubbleShape(position: position, outgoing: outgoing)) - .frame(maxWidth: maxBubbleWidth) - } - } - - 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" - } - - func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] { - var actions: [BubbleContextAction] = [] - - actions.append(BubbleContextAction( - title: "Reply", - image: UIImage(systemName: "arrowshape.turn.up.left"), - role: [] - ) { - self.replyingToMessage = message - self.isInputFocused = true - }) - - actions.append(BubbleContextAction( - title: "Copy", - image: UIImage(systemName: "doc.on.doc"), - role: [] - ) { - UIPasteboard.general.string = message.text - }) - - // No forward for avatar messages (Android parity) - if !message.attachments.contains(where: { $0.type == .avatar }) { - actions.append(BubbleContextAction( - title: "Forward", - image: UIImage(systemName: "arrowshape.turn.up.right"), - role: [] - ) { - self.forwardingMessage = message - self.showForwardPicker = true - }) - } - - actions.append(BubbleContextAction( - title: "Delete", - image: UIImage(systemName: "trash"), - role: .destructive - ) { - self.messageToDelete = message - }) - - return actions - } - - /// Determines which attachment was tapped in a photo collage based on tap location. - /// Mirrors the layout logic in PhotoCollageView (spacing=2, same proportions). - 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 - } + /// Lightweight reply blob parser for image viewer — delegates to MessageCellView cache. + private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? { + guard !blob.isEmpty, let data = blob.data(using: .utf8) else { return nil } + return try? JSONDecoder().decode([ReplyMessageData].self, from: data) } /// Collects all image attachments from the current chat and opens the gallery. @@ -2563,7 +1665,7 @@ private extension ChatDetailView { } // Filter garbage text (U+FFFD from failed decryption) — don't send ciphertext/garbage to recipient. - let cleanText = Self.isGarbageText(message.text) ? "" : message.text + let cleanText = MessageCellView.isGarbageText(message.text) ? "" : message.text return ReplyMessageData( message_id: message.id, @@ -2574,20 +1676,6 @@ private extension ChatDetailView { ) } - /// PERF: static cache for formatted timestamps — avoids Date + DateFormatter per cell per render. - @MainActor private static var timeCache: [Int64: String] = [:] - - func messageTime(_ timestamp: Int64) -> String { - if let cached = Self.timeCache[timestamp] { return cached } - let result = Self.timeFormatter.string(from: Date(timeIntervalSince1970: Double(timestamp) / 1000)) - // PERF: evict half instead of clearing all — timestamps are reused during scroll. - 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 - } func scrollToBottom(proxy: ScrollViewProxy, animated: Bool) { // Inverted scroll: .top anchor in scroll coordinates = visual bottom on screen. @@ -2731,13 +1819,6 @@ private extension ChatDetailView { func handleComposerUserTyping() { SessionManager.shared.sendTypingIndicator(toPublicKey: route.publicKey) } - - static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "HH:mm" - return formatter - }() } // MARK: - Nav Bar Style (ChatDetail-specific) @@ -2830,38 +1911,6 @@ enum TelegramIconPath { static let microphone = #"M3.69141 5.09766C3.69141 4.16016 3.91602 3.30078 4.36523 2.51953C4.79492 1.75781 5.38086 1.14258 6.12305 0.673828C6.88477 0.224609 7.70508 0 8.58398 0C9.44336 0 10.2441 0.214844 10.9863 0.644531C11.7285 1.07422 12.3145 1.66016 12.7441 2.40234C13.1934 3.16406 13.4375 3.98438 13.4766 4.86328V5.09766V10.8105C13.4766 11.748 13.252 12.6074 12.8027 13.3887C12.373 14.1504 11.7871 14.7559 11.0449 15.2051C10.2832 15.6738 9.46289 15.9082 8.58398 15.9082C7.72461 15.9082 6.92383 15.6934 6.18164 15.2637C5.43945 14.834 4.85352 14.248 4.42383 13.5059C3.97461 12.7441 3.73047 11.9238 3.69141 11.0449V10.8105V5.09766ZM8.58398 1.58203C7.99805 1.58203 7.45117 1.72852 6.94336 2.02148C6.43555 2.31445 6.03516 2.71484 5.74219 3.22266C5.42969 3.73047 5.25391 4.28711 5.21484 4.89258V5.09766V10.8105C5.21484 11.4551 5.37109 12.0508 5.68359 12.5977C5.97656 13.125 6.37695 13.5449 6.88477 13.8574C7.41211 14.1699 7.97852 14.3262 8.58398 14.3262C9.16992 14.3262 9.7168 14.1797 10.2246 13.8867C10.7324 13.5938 11.1328 13.1934 11.4258 12.6855C11.7383 12.1777 11.9141 11.6211 11.9531 11.0156V10.8105V5.09766C11.9531 4.45312 11.7969 3.85742 11.4844 3.31055C11.1914 2.7832 10.791 2.36328 10.2832 2.05078C9.75586 1.73828 9.18945 1.58203 8.58398 1.58203ZM9.3457 19.7168V22.7637C9.3457 22.9785 9.26758 23.1641 9.11133 23.3203C8.97461 23.4766 8.79883 23.5547 8.58398 23.5547C8.38867 23.5547 8.22266 23.4863 8.08594 23.3496C7.92969 23.2324 7.8418 23.0762 7.82227 22.8809V22.7637V19.7168C6.74805 19.5996 5.72266 19.2969 4.74609 18.8086C3.80859 18.3203 2.98828 17.666 2.28516 16.8457C1.5625 16.0449 1.00586 15.1367 0.615234 14.1211C0.205078 13.0664 0 11.9629 0 10.8105C0 10.5957 0.078125 10.4102 0.234375 10.2539C0.390625 10.0977 0.566406 10.0195 0.761719 10.0195C0.976562 10.0195 1.16211 10.0977 1.31836 10.2539C1.45508 10.4102 1.52344 10.5957 1.52344 10.8105C1.52344 11.8066 1.70898 12.7637 2.08008 13.6816C2.45117 14.5605 2.95898 15.332 3.60352 15.9961C4.24805 16.6797 4.99023 17.207 5.83008 17.5781C6.70898 17.9688 7.62695 18.1641 8.58398 18.1641C9.54102 18.1641 10.459 17.9688 11.3379 17.5781C12.1777 17.207 12.9199 16.6797 13.5645 15.9961C14.209 15.332 14.7168 14.5605 15.0879 13.6816C15.459 12.7637 15.6445 11.8066 15.6445 10.8105C15.6445 10.5957 15.7129 10.4102 15.8496 10.2539C16.0059 10.0977 16.1914 10.0195 16.4062 10.0195C16.6016 10.0195 16.7773 10.0977 16.9336 10.2539C17.0898 10.4102 17.168 10.5957 17.168 10.8105C17.168 11.9629 16.9629 13.0664 16.5527 14.1211C16.1621 15.1367 15.6055 16.0449 14.8828 16.8457C14.1797 17.666 13.3594 18.3203 12.4219 18.8086C11.4453 19.2969 10.4199 19.5996 9.3457 19.7168Z"# } -/// iOS < 26: ignore keyboard safe area — keyboardPadding (sync view + CADisplayLink) -/// drives both composer offset and spacer height. One source of truth = perfect sync. -/// iOS 26+: SwiftUI handles keyboard natively. -private struct IgnoreKeyboardSafeAreaLegacy: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - // iOS 26+: SwiftUI handles keyboard natively — don't block it. - // KeyboardTracker is inert on iOS 26 (init returns early). - content - } else { - // iOS < 26: disable SwiftUI's native keyboard avoidance. - // Inverted scroll (scaleEffect y: -1) breaks native avoidance — it pushes - // content in the wrong direction. KeyboardSpacer + ComposerHostView handle - // keyboard offset manually via KeyboardTracker. - content.ignoresSafeArea(.keyboard) - } - } -} - -/// iOS < 26: prevent ScrollView from adjusting for keyboard — -/// parent .safeAreaInset already handles it. Without this, -/// both parent AND ScrollView adjust → double-counting → jerky animation. -/// iOS 26 handles this internally. -private struct ScrollIgnoreKeyboardLegacy: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 26, *) { - content - } else { - content.ignoresSafeArea(.keyboard) - } - } -} /// Sets initial scroll position to bottom. /// iOS 18+: `.initialOffset` only — don't re-anchor on container size changes @@ -2877,28 +1926,20 @@ private struct DefaultScrollAnchorModifier: ViewModifier { } } -/// Composer overlay with keyboard offset — observation isolated. -/// Composer offset by keyboardPadding — driven by sync view + CADisplayLink, -/// reading the keyboard's REAL position each frame. Pixel-perfect sync. +/// Composer overlay pinned to the bottom of the container. +/// Keyboard offset is handled by KeyboardSyncedContainer (iOS < 26) or +/// SwiftUI native keyboard handling (iOS 26+) — no manual padding needed. private struct ComposerOverlay: View { let composer: C @Binding var composerHeight: CGFloat - @ObservedObject private var keyboard = KeyboardTracker.shared var body: some View { - let pad = keyboard.keyboardPadding - #if DEBUG - let _ = { - print("🎹 Composer | pad=\(Int(pad)) composerH=\(Int(composerHeight))") - }() - #endif composer .background( GeometryReader { geo in Color.clear.preference(key: ComposerHeightKey.self, value: geo.size.height) } ) - .padding(.bottom, pad) .frame(maxHeight: .infinity, alignment: .bottom) } } @@ -2918,11 +1959,39 @@ private struct DisableScrollEdgeEffectModifier: ViewModifier { } } +/// 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). /// Uses ForwardedImagePreviewCell for each cell instead of MessageImageView. -private struct ForwardedPhotoCollageView: View { +struct ForwardedPhotoCollageView: View { let attachments: [ReplyAttachmentData] let outgoing: Bool let maxWidth: CGFloat @@ -3018,7 +2087,7 @@ private struct ForwardedPhotoCollageView: View { /// updates when `AttachmentCache` is populated by `MessageImageView` downloading the original. /// Without this, a plain `let cachedImage = ...` in the parent body is a one-shot evaluation /// that never re-checks the cache. -private struct ForwardedImagePreviewCell: View { +struct ForwardedImagePreviewCell: View { let attachment: ReplyAttachmentData let width: CGFloat var fixedHeight: CGFloat? @@ -3064,7 +2133,7 @@ private struct ForwardedImagePreviewCell: View { .task { // Decode blurhash from preview field ("cdnTag::blurhash" or just "blurhash"). if let hash = extractBlurHash(), !hash.isEmpty { - blurImage = ChatDetailView.cachedBlurHash(hash, width: 64, height: 64) + blurImage = MessageCellView.cachedBlurHash(hash, width: 64, height: 64) } // Check cache immediately — image may already be there. @@ -3099,7 +2168,7 @@ private struct ForwardedImagePreviewCell: View { /// 32×32 thumbnail for reply quote views. Checks `AttachmentCache` first for the actual /// downloaded image, falling back to blurhash. Uses `@State` + `.task` with retry polling /// so the thumbnail updates when the original `MessageImageView` finishes downloading. -private struct ReplyQuoteThumbnail: View { +struct ReplyQuoteThumbnail: View { let attachment: ReplyAttachmentData let blurHash: String? @@ -3110,7 +2179,7 @@ private struct ReplyQuoteThumbnail: View { // Blurhash is computed synchronously (static cache) so it shows on the first frame. // cachedImage overrides it when the real photo is available in AttachmentCache. let image = cachedImage ?? blurHash.flatMap { - ChatDetailView.cachedBlurHash($0, width: 32, height: 32) + MessageCellView.cachedBlurHash($0, width: 32, height: 32) } Group { diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index a20a76a..bf3df5e 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -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() @@ -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 + } } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift new file mode 100644 index 0000000..4623df0 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -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 } +} diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift new file mode 100644 index 0000000..1be1382 --- /dev/null +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellView.swift @@ -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) + } +}