Equatable-ячейки сообщений, пагинация скролла, оптимизация removeDuplicates
This commit is contained in:
@@ -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 = "";
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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). Прочтение больше не перезаписывает ошибочные сообщения.
|
||||
**Доставка и синхронизация**
|
||||
Сообщения больше не помечаются ошибкой при кратковременном обрыве — часики и автодоставка при реконнекте. Прочтения от оппонента корректно синхронизируются.
|
||||
"""
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1,64 +1,102 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Wraps SwiftUI content in a UIKit container whose vertical position is animated
|
||||
/// using the keyboard's exact Core Animation curve. This achieves Telegram-level
|
||||
/// keyboard sync because both the keyboard and this container are animated by the
|
||||
/// render server in the same Core Animation transaction — zero relative movement.
|
||||
/// Telegram-style keyboard synchronization: list and composer are TWO independent
|
||||
/// UIHostingControllers within a single UIViewController.
|
||||
///
|
||||
/// On iOS 26+, SwiftUI handles keyboard natively — this wrapper is a no-op passthrough.
|
||||
struct KeyboardSyncedContainer<Content: View>: View {
|
||||
/// - Composer is pinned to `keyboardLayoutGuide.topAnchor` — UIKit physically moves
|
||||
/// its position Y when keyboard appears (no SwiftUI relayout).
|
||||
/// - List extends to the same bottom as composer (under it) for glass/blur effect.
|
||||
/// - Inverted ScrollView inside list keeps messages glued to the input bar.
|
||||
/// - Interactive dismiss follows automatically via keyboardLayoutGuide.
|
||||
/// - Composer height reported from UIKit (`view.bounds.height`) — automatically
|
||||
/// includes safe area when keyboard hidden, excludes when keyboard open.
|
||||
/// - Nav bar height reported via `onTopSafeAreaChange` — SwiftUI uses it for
|
||||
/// `.safeAreaInset(edge: .bottom)` (= visual top in inverted scroll).
|
||||
///
|
||||
/// On iOS 26+, SwiftUI handles keyboard natively — passthrough for content only.
|
||||
struct KeyboardSyncedContainer<Content: View, Composer: View>: View {
|
||||
let content: Content
|
||||
let composer: Composer
|
||||
var onComposerHeightChange: ((CGFloat) -> Void)?
|
||||
var onTopSafeAreaChange: ((CGFloat) -> Void)?
|
||||
|
||||
init(@ViewBuilder content: () -> Content) {
|
||||
init(
|
||||
@ViewBuilder content: () -> Content,
|
||||
@ViewBuilder composer: () -> Composer,
|
||||
onComposerHeightChange: ((CGFloat) -> Void)? = nil,
|
||||
onTopSafeAreaChange: ((CGFloat) -> Void)? = nil
|
||||
) {
|
||||
self.content = content()
|
||||
self.composer = composer()
|
||||
self.onComposerHeightChange = onComposerHeightChange
|
||||
self.onTopSafeAreaChange = onTopSafeAreaChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 26, *) {
|
||||
// iOS 26+: caller handles composer via overlay. Container is passthrough.
|
||||
content
|
||||
} else {
|
||||
_KeyboardSyncedRepresentable(content: content)
|
||||
.ignoresSafeArea(.keyboard)
|
||||
_KeyboardSyncedRepresentable(
|
||||
content: content,
|
||||
composer: composer,
|
||||
onComposerHeightChange: onComposerHeightChange,
|
||||
onTopSafeAreaChange: onTopSafeAreaChange
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerRepresentable bridge
|
||||
|
||||
private struct _KeyboardSyncedRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||
private struct _KeyboardSyncedRepresentable<
|
||||
Content: View,
|
||||
Composer: View
|
||||
>: UIViewControllerRepresentable {
|
||||
let content: Content
|
||||
let composer: Composer
|
||||
var onComposerHeightChange: ((CGFloat) -> Void)?
|
||||
var onTopSafeAreaChange: ((CGFloat) -> Void)?
|
||||
|
||||
func makeUIViewController(context: Context) -> _KeyboardSyncedVC<Content> {
|
||||
_KeyboardSyncedVC(rootView: content)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ vc: _KeyboardSyncedVC<Content>, context: Context) {
|
||||
vc.hostingController.rootView = content
|
||||
}
|
||||
|
||||
func sizeThatFits(
|
||||
_ proposal: ProposedViewSize,
|
||||
uiViewController vc: _KeyboardSyncedVC<Content>,
|
||||
func makeUIViewController(
|
||||
context: Context
|
||||
) -> CGSize? {
|
||||
let width = proposal.width ?? UIScreen.main.bounds.width
|
||||
let fittingSize = vc.hostingController.sizeThatFits(
|
||||
in: CGSize(width: width, height: UIView.layoutFittingCompressedSize.height)
|
||||
)
|
||||
return CGSize(width: width, height: fittingSize.height)
|
||||
) -> _KeyboardSyncedVC<Content, Composer> {
|
||||
let vc = _KeyboardSyncedVC(content: content, composer: composer)
|
||||
vc.onComposerHeightChange = onComposerHeightChange
|
||||
vc.onTopSafeAreaChange = onTopSafeAreaChange
|
||||
return vc
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ vc: _KeyboardSyncedVC<Content, Composer>,
|
||||
context: Context
|
||||
) {
|
||||
vc.listController.rootView = content
|
||||
vc.composerController.rootView = composer
|
||||
vc.onComposerHeightChange = onComposerHeightChange
|
||||
vc.onTopSafeAreaChange = onTopSafeAreaChange
|
||||
}
|
||||
|
||||
// No sizeThatFits — container fills all proposed space from parent.
|
||||
}
|
||||
|
||||
// MARK: - UIKit view controller that animates with the keyboard
|
||||
// MARK: - UIKit view controller: two hosting controllers
|
||||
|
||||
final class _KeyboardSyncedVC<Content: View>: UIViewController {
|
||||
final class _KeyboardSyncedVC<Content: View, Composer: View>: UIViewController, UIGestureRecognizerDelegate {
|
||||
|
||||
let hostingController: UIHostingController<Content>
|
||||
private var bottomInset: CGFloat = 34
|
||||
let listController: UIHostingController<Content>
|
||||
let composerController: UIHostingController<Composer>
|
||||
|
||||
init(rootView: Content) {
|
||||
hostingController = UIHostingController(rootView: rootView)
|
||||
var onComposerHeightChange: ((CGFloat) -> Void)?
|
||||
var onTopSafeAreaChange: ((CGFloat) -> Void)?
|
||||
private var lastReportedComposerHeight: CGFloat = 0
|
||||
private var lastReportedTopSafeArea: CGFloat = 0
|
||||
|
||||
init(content: Content, composer: Composer) {
|
||||
listController = UIHostingController(rootView: content)
|
||||
composerController = UIHostingController(rootView: composer)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@@ -69,56 +107,105 @@ final class _KeyboardSyncedVC<Content: View>: UIViewController {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = .clear
|
||||
|
||||
addChild(hostingController)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
view.addSubview(hostingController.view)
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
hostingController.didMove(toParent: self)
|
||||
|
||||
// Read safe area bottom inset
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = scene.keyWindow ?? scene.windows.first {
|
||||
let bottom = window.safeAreaInsets.bottom
|
||||
bottomInset = bottom < 50 ? bottom : 34
|
||||
// Configure list hosting controller — NO safe area (clean rectangle).
|
||||
// UIKit transform (y: -1) inverts safe areas which breaks UIScrollView math.
|
||||
// Nav bar inset is bridged to SwiftUI via onTopSafeAreaChange callback.
|
||||
listController.view.backgroundColor = .clear
|
||||
if #available(iOS 16.4, *) {
|
||||
listController.safeAreaRegions = []
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(keyboardWillChangeFrame),
|
||||
name: UIResponder.keyboardWillChangeFrameNotification,
|
||||
object: nil
|
||||
// Configure composer hosting controller
|
||||
composerController.view.backgroundColor = .clear
|
||||
if #available(iOS 16.4, *) {
|
||||
composerController.safeAreaRegions = .container
|
||||
}
|
||||
if #available(iOS 16.0, *) {
|
||||
composerController.sizingOptions = .intrinsicContentSize
|
||||
}
|
||||
composerController.view.setContentHuggingPriority(.required, for: .vertical)
|
||||
composerController.view.setContentCompressionResistancePriority(
|
||||
.required, for: .vertical
|
||||
)
|
||||
|
||||
// Add children — composer on top of list (z-order)
|
||||
addChild(listController)
|
||||
addChild(composerController)
|
||||
view.addSubview(listController.view)
|
||||
view.addSubview(composerController.view)
|
||||
|
||||
listController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
composerController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Telegram-style inversion: flip the list UIView, NOT the SwiftUI ScrollView.
|
||||
listController.view.transform = CGAffineTransform(scaleX: 1, y: -1)
|
||||
|
||||
// When keyboard is hidden, guide top = view bottom (not safe area bottom).
|
||||
view.keyboardLayoutGuide.usesBottomSafeArea = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
composerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
composerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
composerController.view.bottomAnchor.constraint(
|
||||
equalTo: view.keyboardLayoutGuide.topAnchor
|
||||
),
|
||||
|
||||
// Fixed height = screen height. List slides up as a unit when keyboard opens.
|
||||
listController.view.heightAnchor.constraint(equalTo: view.heightAnchor),
|
||||
listController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
listController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
listController.view.bottomAnchor.constraint(
|
||||
equalTo: view.keyboardLayoutGuide.topAnchor
|
||||
),
|
||||
])
|
||||
|
||||
listController.didMove(toParent: self)
|
||||
composerController.didMove(toParent: self)
|
||||
|
||||
// Swipe down on composer to dismiss keyboard.
|
||||
let panGesture = UIPanGestureRecognizer(
|
||||
target: self, action: #selector(handleComposerPan(_:))
|
||||
)
|
||||
panGesture.delegate = self
|
||||
composerController.view.addGestureRecognizer(panGesture)
|
||||
}
|
||||
|
||||
@objc private func keyboardWillChangeFrame(_ notification: Notification) {
|
||||
guard let info = notification.userInfo,
|
||||
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect,
|
||||
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
|
||||
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int
|
||||
else { return }
|
||||
|
||||
let screenHeight = UIScreen.main.bounds.height
|
||||
let keyboardTop = endFrame.origin.y
|
||||
let isVisible = keyboardTop < screenHeight
|
||||
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
|
||||
let padding = isVisible ? max(0, endHeight - bottomInset) : 0
|
||||
|
||||
// Animate with the KEYBOARD'S EXACT curve in the SAME Core Animation transaction.
|
||||
// The render server interpolates both the keyboard position and our transform
|
||||
// together for each frame — pixel-perfect sync, zero gap variation.
|
||||
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
|
||||
UIView.animate(withDuration: duration, delay: 0, options: [options, .beginFromCurrentState]) {
|
||||
self.view.transform = CGAffineTransform(translationX: 0, y: -padding)
|
||||
@objc private func handleComposerPan(_ gesture: UIPanGestureRecognizer) {
|
||||
let translation = gesture.translation(in: composerController.view)
|
||||
let velocity = gesture.velocity(in: composerController.view)
|
||||
if translation.y > 10 && velocity.y > 100 {
|
||||
view.endEditing(true)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func viewSafeAreaInsetsDidChange() {
|
||||
super.viewSafeAreaInsetsDidChange()
|
||||
// Bridge: UIKit measures exact nav bar height → SwiftUI applies via .safeAreaInset.
|
||||
// No additionalSafeAreaInsets (negative values break UIScrollView math).
|
||||
let navBarHeight = view.safeAreaInsets.top
|
||||
if abs(navBarHeight - lastReportedTopSafeArea) > 1 {
|
||||
lastReportedTopSafeArea = navBarHeight
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onTopSafeAreaChange?(navBarHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
let height = composerController.view.bounds.height
|
||||
if height > 0, abs(height - lastReportedComposerHeight) > 1 {
|
||||
lastReportedComposerHeight = height
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.onComposerHeightChange?(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,21 +79,24 @@ final class KeyboardTracker: ObservableObject {
|
||||
bottomInset = 34
|
||||
}
|
||||
|
||||
// iOS 26+: SwiftUI handles keyboard natively — no tracking needed.
|
||||
if #available(iOS 26, *) { return }
|
||||
// KeyboardSyncedContainer handles keyboard via keyboardLayoutGuide (iOS < 26).
|
||||
// SwiftUI handles keyboard natively (iOS 26+).
|
||||
// No notification/CADisplayLink/KVO tracking needed for either version.
|
||||
// Legacy code below disabled — kept for reference.
|
||||
if false {
|
||||
// iOS < 26: sync view + CADisplayLink — reads keyboard's REAL position
|
||||
// each frame from the same CA transaction. Pixel-perfect sync.
|
||||
NotificationCenter.default
|
||||
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
||||
.sink { [weak self] in self?.handleNotification($0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
// iOS < 26: sync view + CADisplayLink — reads keyboard's REAL position
|
||||
// each frame from the same CA transaction. Pixel-perfect sync.
|
||||
NotificationCenter.default
|
||||
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
|
||||
.sink { [weak self] in self?.handleNotification($0) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Pre-create display link (paused) — avoids allocation overhead on first keyboard show.
|
||||
displayLinkProxy = DisplayLinkProxy { [weak self] in
|
||||
self?.animationTick()
|
||||
// Pre-create display link (paused) — avoids allocation overhead on first keyboard show.
|
||||
displayLinkProxy = DisplayLinkProxy { [weak self] in
|
||||
self?.animationTick()
|
||||
}
|
||||
displayLinkProxy?.isPaused = true
|
||||
}
|
||||
displayLinkProxy?.isPaused = true
|
||||
}
|
||||
|
||||
/// Sets keyboardPadding with animation matching keyboard duration.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,10 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
@Published private(set) var isTyping: Bool = false
|
||||
/// Android parity: true while loading messages from DB. Shows skeleton placeholder.
|
||||
@Published private(set) var isLoading: Bool = true
|
||||
/// Pagination: true while older messages are available in SQLite.
|
||||
@Published private(set) var hasMoreMessages: Bool = true
|
||||
/// Pagination: guard against concurrent loads.
|
||||
@Published private(set) var isLoadingMore: Bool = false
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -29,6 +33,10 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
// Android parity: if we already have messages, skip skeleton.
|
||||
// Otherwise keep isLoading=true until first Combine emission or timeout.
|
||||
isLoading = initial.isEmpty
|
||||
// If initial load returned fewer than pageSize, no more to load.
|
||||
if initial.count < MessageRepository.pageSize {
|
||||
hasMoreMessages = false
|
||||
}
|
||||
|
||||
// Subscribe to messagesByDialog changes, filtered to our dialog only.
|
||||
// Broken into steps to help the Swift type-checker.
|
||||
@@ -43,7 +51,9 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
.removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
for i in lhs.indices {
|
||||
if lhs[i].id != rhs[i].id || lhs[i].deliveryStatus != rhs[i].deliveryStatus {
|
||||
if lhs[i].id != rhs[i].id ||
|
||||
lhs[i].deliveryStatus != rhs[i].deliveryStatus ||
|
||||
lhs[i].isRead != rhs[i].isRead {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -74,4 +84,23 @@ final class ChatDetailViewModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Pagination: load older messages from SQLite when user scrolls to top.
|
||||
func loadMore() async {
|
||||
guard !isLoadingMore, hasMoreMessages else { return }
|
||||
guard let earliest = messages.first else { return }
|
||||
isLoadingMore = true
|
||||
|
||||
let older = MessageRepository.shared.loadOlderMessages(
|
||||
for: dialogKey,
|
||||
beforeTimestamp: earliest.timestamp,
|
||||
limit: MessageRepository.pageSize
|
||||
)
|
||||
|
||||
if older.count < MessageRepository.pageSize {
|
||||
hasMoreMessages = false
|
||||
}
|
||||
// messages will update via Combine pipeline (repo already prepends to cache).
|
||||
isLoadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
16
Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift
Normal file
16
Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
/// Stable callback reference for message cell interactions.
|
||||
/// Class ref means SwiftUI sees the same pointer on parent re-render,
|
||||
/// so cells are NOT marked dirty due to closure diffing (memcmp).
|
||||
@MainActor
|
||||
final class MessageCellActions {
|
||||
var onReply: (ChatMessage) -> Void = { _ in }
|
||||
var onForward: (ChatMessage) -> Void = { _ in }
|
||||
var onDelete: (ChatMessage) -> Void = { _ in }
|
||||
var onCopy: (String) -> Void = { _ in }
|
||||
var onImageTap: (String) -> Void = { _ in }
|
||||
var onScrollToMessage: (String) -> Void = { _ in }
|
||||
var onRetry: (ChatMessage) -> Void = { _ in }
|
||||
var onRemove: (ChatMessage) -> Void = { _ in }
|
||||
}
|
||||
843
Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
Normal file
843
Rosetta/Features/Chats/ChatDetail/MessageCellView.swift
Normal file
@@ -0,0 +1,843 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Equatable message cell — SwiftUI skips body re-evaluation when inputs haven't changed.
|
||||
/// Extracted from ChatDetailView to create an Equatable boundary for `.equatable()` modifier.
|
||||
struct MessageCellView: View, Equatable {
|
||||
let message: ChatMessage
|
||||
let maxBubbleWidth: CGFloat
|
||||
let position: BubblePosition
|
||||
let currentPublicKey: String
|
||||
let highlightedMessageId: String?
|
||||
let isSavedMessages: Bool
|
||||
let isSystemAccount: Bool
|
||||
let opponentPublicKey: String
|
||||
let opponentTitle: String
|
||||
let opponentUsername: String
|
||||
let actions: MessageCellActions
|
||||
|
||||
static func == (lhs: MessageCellView, rhs: MessageCellView) -> Bool {
|
||||
lhs.message == rhs.message &&
|
||||
lhs.maxBubbleWidth == rhs.maxBubbleWidth &&
|
||||
lhs.position == rhs.position &&
|
||||
lhs.highlightedMessageId == rhs.highlightedMessageId
|
||||
// currentPublicKey, isSavedMessages, isSystemAccount, opponent* — stable per chat session
|
||||
// actions — class ref, excluded (pointer identity is unstable across re-renders)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let _ = PerformanceLogger.shared.track("chatDetail.rowEval")
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
let hasTail = position == .single || position == .bottom
|
||||
|
||||
let visibleAttachments = message.attachments.filter {
|
||||
$0.type == .image || $0.type == .file || $0.type == .avatar
|
||||
}
|
||||
|
||||
Group {
|
||||
if visibleAttachments.isEmpty {
|
||||
let hasReplyAttachment = message.attachments.contains(where: { $0.type == .messages })
|
||||
if hasReplyAttachment || !Self.isGarbageText(message.text) {
|
||||
textOnlyBubble(
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
hasTail: hasTail,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
position: position
|
||||
)
|
||||
}
|
||||
} else {
|
||||
attachmentBubble(
|
||||
message: message,
|
||||
attachments: visibleAttachments,
|
||||
outgoing: outgoing,
|
||||
hasTail: hasTail,
|
||||
maxBubbleWidth: maxBubbleWidth,
|
||||
position: position
|
||||
)
|
||||
}
|
||||
}
|
||||
.modifier(ConditionalSwipeToReply(
|
||||
enabled: !isSavedMessages && !isSystemAccount,
|
||||
onReply: { actions.onReply(message) }
|
||||
))
|
||||
.overlay {
|
||||
if highlightedMessageId == message.id {
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: outgoing ? .trailing : .leading)
|
||||
.padding(.trailing, outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.leading, !outgoing ? (hasTail ? 2 : MessageBubbleShape.tailProtrusion + 2) : 0)
|
||||
.padding(.top, (position == .single || position == .top) ? 6 : 2)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
// MARK: - Text-Only Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private func textOnlyBubble(
|
||||
message: ChatMessage, outgoing: Bool, hasTail: Bool,
|
||||
maxBubbleWidth: CGFloat, position: BubblePosition
|
||||
) -> some View {
|
||||
let messageText = message.text.isEmpty ? " " : message.text
|
||||
let replyAttachment = message.attachments.first(where: { $0.type == .messages })
|
||||
let replyData = replyAttachment.flatMap {
|
||||
parseReplyBlob($0.blob) ?? parseReplyBlob($0.preview)
|
||||
}?.first
|
||||
let isForward = (message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| Self.isGarbageText(message.text)) && replyData != nil
|
||||
|
||||
if isForward, let reply = replyData {
|
||||
forwardedMessageBubble(
|
||||
message: message, reply: reply, outgoing: outgoing,
|
||||
hasTail: hasTail, maxBubbleWidth: maxBubbleWidth, position: position
|
||||
)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if let reply = replyData {
|
||||
replyQuoteView(reply: reply, outgoing: outgoing)
|
||||
}
|
||||
|
||||
Text(parsedMarkdown(messageText))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
replyQuoteHeight: replyData != nil ? 46 : 0,
|
||||
onReplyQuoteTap: replyData.map { reply in
|
||||
{ [reply] in actions.onScrollToMessage(reply.message_id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Forwarded Message Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private func forwardedMessageBubble(
|
||||
message: ChatMessage, reply: ReplyMessageData, outgoing: Bool,
|
||||
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
|
||||
) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let senderInitials = RosettaColors.initials(name: senderName, publicKey: reply.publicKey)
|
||||
let senderColorIndex = RosettaColors.avatarColorIndex(for: senderName, publicKey: reply.publicKey)
|
||||
let senderAvatar = AvatarRepository.shared.loadAvatar(publicKey: reply.publicKey)
|
||||
|
||||
let imageAttachments = reply.attachments.filter { $0.type == 0 }
|
||||
let fileAttachments = reply.attachments.filter { $0.type == 2 }
|
||||
let hasVisualAttachments = !imageAttachments.isEmpty || !fileAttachments.isEmpty
|
||||
|
||||
let hasCaption = !reply.message.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !Self.isGarbageText(reply.message)
|
||||
|
||||
#if DEBUG
|
||||
let _ = {
|
||||
if reply.attachments.isEmpty {
|
||||
print("⚠️ Forward bubble: reply has NO attachments. message_id=\(reply.message_id), text='\(reply.message.prefix(50))', publicKey=\(reply.publicKey.prefix(12))")
|
||||
if let att = message.attachments.first(where: { $0.type == .messages }) {
|
||||
let blobPrefix = att.blob.prefix(60)
|
||||
let isEncrypted = att.blob.contains(":") && !att.blob.hasPrefix("[")
|
||||
print("⚠️ raw .messages blob (\(att.blob.count) chars): '\(blobPrefix)...' encrypted=\(isEncrypted)")
|
||||
}
|
||||
} else {
|
||||
print("📋 Forward bubble: message_id=\(reply.message_id.prefix(16)), \(reply.attachments.count) atts (images=\(imageAttachments.count), files=\(fileAttachments.count)), caption=\(hasCaption)")
|
||||
}
|
||||
}()
|
||||
#endif
|
||||
|
||||
let fallbackText: String = {
|
||||
if hasCaption { return reply.message }
|
||||
if !imageAttachments.isEmpty { return hasVisualAttachments ? "" : "Photo" }
|
||||
if let file = fileAttachments.first {
|
||||
let parts = file.preview.components(separatedBy: "::")
|
||||
if parts.count > 2 { return parts[2] }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if reply.attachments.contains(where: { $0.type == 3 }) { return "Avatar" }
|
||||
return "Message"
|
||||
}()
|
||||
|
||||
let imageContentWidth = maxBubbleWidth - 22
|
||||
- (outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
- (!outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("Forwarded from")
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.5) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 6)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
AvatarView(
|
||||
initials: senderInitials,
|
||||
colorIndex: senderColorIndex,
|
||||
size: 20,
|
||||
image: senderAvatar
|
||||
)
|
||||
Text(senderName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 11)
|
||||
.padding(.top, 3)
|
||||
|
||||
if !imageAttachments.isEmpty {
|
||||
ForwardedPhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
outgoing: outgoing,
|
||||
maxWidth: imageContentWidth,
|
||||
onImageTap: { attId in actions.onImageTap(attId) }
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
ForEach(fileAttachments, id: \.id) { att in
|
||||
forwardedFilePreview(attachment: att, outgoing: outgoing)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(reply.message))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else if !hasVisualAttachments {
|
||||
Text(fallbackText)
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 3)
|
||||
.padding(.bottom, 5)
|
||||
} else {
|
||||
Spacer().frame(height: 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 130, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !imageAttachments.isEmpty && !hasCaption {
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !imageAttachments.isEmpty ? { _ in
|
||||
if let firstId = imageAttachments.first?.id {
|
||||
actions.onImageTap(firstId)
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
// MARK: - Attachment Bubble
|
||||
|
||||
@ViewBuilder
|
||||
private func attachmentBubble(
|
||||
message: ChatMessage, attachments: [MessageAttachment], outgoing: Bool,
|
||||
hasTail: Bool, maxBubbleWidth: CGFloat, position: BubblePosition
|
||||
) -> some View {
|
||||
let hasCaption = Self.isValidCaption(message.text)
|
||||
let partitioned = Self.partitionAttachments(attachments)
|
||||
let imageAttachments = partitioned.images
|
||||
let otherAttachments = partitioned.others
|
||||
let isImageOnly = !imageAttachments.isEmpty && otherAttachments.isEmpty && !hasCaption
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
if !imageAttachments.isEmpty {
|
||||
PhotoCollageView(
|
||||
attachments: imageAttachments,
|
||||
message: message,
|
||||
outgoing: outgoing,
|
||||
maxWidth: maxBubbleWidth - (hasTail ? MessageBubbleShape.tailProtrusion : 0),
|
||||
position: position
|
||||
)
|
||||
}
|
||||
|
||||
ForEach(otherAttachments, id: \.id) { attachment in
|
||||
Group {
|
||||
switch attachment.type {
|
||||
case .file:
|
||||
MessageFileView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.top, 4)
|
||||
case .avatar:
|
||||
MessageAvatarView(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
outgoing: outgoing
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.top, 4)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasCaption {
|
||||
Text(parsedMarkdown(message.text))
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.tracking(-0.43)
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(0)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.leading, 11)
|
||||
.padding(.trailing, outgoing ? 64 : 48)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: outgoing ? 86 : 66, alignment: .leading)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if isImageOnly {
|
||||
mediaTimestampOverlay(message: message, outgoing: outgoing)
|
||||
} else {
|
||||
timestampOverlay(message: message, outgoing: outgoing)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.padding(.leading, !outgoing && hasTail ? MessageBubbleShape.tailProtrusion : 0)
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
onTap: !attachments.isEmpty ? { tapLocation in
|
||||
if !imageAttachments.isEmpty {
|
||||
let tappedId = imageAttachments.count == 1
|
||||
? imageAttachments[0].id
|
||||
: Self.collageAttachmentId(
|
||||
at: tapLocation,
|
||||
attachments: imageAttachments,
|
||||
maxWidth: maxBubbleWidth
|
||||
)
|
||||
if AttachmentCache.shared.loadImage(forAttachmentId: tappedId) != nil {
|
||||
actions.onImageTap(tappedId)
|
||||
} else {
|
||||
NotificationCenter.default.post(
|
||||
name: .triggerAttachmentDownload, object: tappedId
|
||||
)
|
||||
}
|
||||
} else {
|
||||
for att in otherAttachments {
|
||||
NotificationCenter.default.post(
|
||||
name: .triggerAttachmentDownload, object: att.id
|
||||
)
|
||||
}
|
||||
}
|
||||
} : nil
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: maxBubbleWidth, alignment: outgoing ? .trailing : .leading)
|
||||
}
|
||||
|
||||
// MARK: - Timestamp Overlays
|
||||
|
||||
@ViewBuilder
|
||||
private func timestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Text(messageTime(message.timestamp))
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(
|
||||
outgoing
|
||||
? Color.white.opacity(0.55)
|
||||
: RosettaColors.Adaptive.textSecondary.opacity(0.6)
|
||||
)
|
||||
|
||||
if outgoing {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
deliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 11)
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaTimestampOverlay(message: ChatMessage, outgoing: Bool) -> some View {
|
||||
HStack(spacing: 3) {
|
||||
Text(messageTime(message.timestamp))
|
||||
.font(.system(size: 11, weight: .regular))
|
||||
.foregroundStyle(.white)
|
||||
|
||||
if outgoing {
|
||||
if message.deliveryStatus == .error {
|
||||
errorMenu(for: message)
|
||||
} else {
|
||||
mediaDeliveryIndicator(message.deliveryStatus, read: message.isRead)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.black.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
.padding(.trailing, 6)
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
|
||||
// MARK: - Delivery Indicators
|
||||
|
||||
private func deliveryTint(_ status: DeliveryStatus, read: Bool) -> Color {
|
||||
if status == .delivered && read { return Color(hex: 0xA4E2FF) }
|
||||
switch status {
|
||||
case .delivered: return Color.white.opacity(0.5)
|
||||
case .error: return RosettaColors.error
|
||||
default: return Color.white.opacity(0.78)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func deliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||
if status == .delivered && read {
|
||||
DoubleCheckmarkShape()
|
||||
.fill(deliveryTint(status, read: read))
|
||||
.frame(width: 16, height: 8.7)
|
||||
} else {
|
||||
switch status {
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(deliveryTint(status, read: read))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status, read: read))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(deliveryTint(status, read: read))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaDeliveryIndicator(_ status: DeliveryStatus, read: Bool) -> some View {
|
||||
if status == .delivered && read {
|
||||
DoubleCheckmarkShape()
|
||||
.fill(Color.white)
|
||||
.frame(width: 16, height: 8.7)
|
||||
} else {
|
||||
switch status {
|
||||
case .delivered:
|
||||
SingleCheckmarkShape()
|
||||
.fill(Color.white.opacity(0.8))
|
||||
.frame(width: 12, height: 8.8)
|
||||
case .waiting:
|
||||
Image(systemName: "clock")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func errorMenu(for message: ChatMessage) -> some View {
|
||||
Menu {
|
||||
Button {
|
||||
actions.onRetry(message)
|
||||
} label: {
|
||||
Label("Retry", systemImage: "arrow.clockwise")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
actions.onRemove(message)
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(RosettaColors.error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bubble Background
|
||||
|
||||
private var incomingBubbleFill: Color {
|
||||
RosettaColors.adaptive(light: Color(hex: 0x2C2C2E), dark: Color(hex: 0x2C2C2E))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func bubbleBackground(outgoing: Bool, position: BubblePosition) -> some View {
|
||||
let fill = outgoing ? RosettaColors.figmaBlue : incomingBubbleFill
|
||||
MessageBubbleShape(position: position, outgoing: outgoing)
|
||||
.fill(fill)
|
||||
}
|
||||
|
||||
// MARK: - Context Menu
|
||||
|
||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
|
||||
return "Read"
|
||||
}
|
||||
|
||||
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
|
||||
var result: [BubbleContextAction] = []
|
||||
|
||||
result.append(BubbleContextAction(
|
||||
title: "Reply",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left"),
|
||||
role: []
|
||||
) { actions.onReply(message) })
|
||||
|
||||
result.append(BubbleContextAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
role: []
|
||||
) { actions.onCopy(message.text) })
|
||||
|
||||
if !message.attachments.contains(where: { $0.type == .avatar }) {
|
||||
result.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) { actions.onForward(message) })
|
||||
}
|
||||
|
||||
result.append(BubbleContextAction(
|
||||
title: "Delete",
|
||||
image: UIImage(systemName: "trash"),
|
||||
role: .destructive
|
||||
) { actions.onDelete(message) })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Reply Quote
|
||||
|
||||
@ViewBuilder
|
||||
private func replyQuoteView(reply: ReplyMessageData, outgoing: Bool) -> some View {
|
||||
let senderName = senderDisplayName(for: reply.publicKey)
|
||||
let previewText: String = {
|
||||
let trimmed = reply.message.trimmingCharacters(in: .whitespaces)
|
||||
if !trimmed.isEmpty { return reply.message }
|
||||
if reply.attachments.contains(where: { $0.type == AttachmentType.image.rawValue }) { return "Photo" }
|
||||
if reply.attachments.contains(where: { $0.type == AttachmentType.messages.rawValue }) { return "Forwarded message" }
|
||||
if reply.attachments.contains(where: { $0.type == AttachmentType.file.rawValue }) { return "File" }
|
||||
if reply.attachments.contains(where: { $0.type == AttachmentType.avatar.rawValue }) { return "Avatar" }
|
||||
return "Attachment"
|
||||
}()
|
||||
let accentColor = outgoing ? Color.white.opacity(0.5) : RosettaColors.figmaBlue
|
||||
let imageAttachment = reply.attachments.first(where: { $0.type == 0 })
|
||||
let blurHash: String? = {
|
||||
guard let att = imageAttachment, !att.preview.isEmpty else { return nil }
|
||||
let parts = att.preview.components(separatedBy: "::")
|
||||
let hash = parts.count > 1 ? parts[1] : att.preview
|
||||
return hash.isEmpty ? nil : hash
|
||||
}()
|
||||
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(accentColor)
|
||||
.frame(width: 3)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if let att = imageAttachment {
|
||||
ReplyQuoteThumbnail(attachment: att, blurHash: blurHash)
|
||||
.padding(.leading, 6)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(senderName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.85) : RosettaColors.figmaBlue)
|
||||
.lineLimit(1)
|
||||
Text(previewText)
|
||||
.font(.system(size: 15, weight: .regular))
|
||||
.tracking(-0.23)
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.Adaptive.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.leading, 6)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: 41)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.top, 5)
|
||||
.padding(.bottom, 0)
|
||||
}
|
||||
|
||||
// MARK: - Forwarded File Preview
|
||||
|
||||
@ViewBuilder
|
||||
private func forwardedFilePreview(attachment: ReplyAttachmentData, outgoing: Bool) -> some View {
|
||||
let filename: String = {
|
||||
let parts = attachment.preview.components(separatedBy: "::")
|
||||
if parts.count > 2 { return parts[2] }
|
||||
return attachment.id.isEmpty ? "File" : attachment.id
|
||||
}()
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "doc.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(outgoing ? Color.white.opacity(0.7) : RosettaColors.figmaBlue)
|
||||
Text(filename)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(outgoing ? Color.white : RosettaColors.Adaptive.text)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(outgoing ? Color.white.opacity(0.08) : Color.white.opacity(0.06))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Collage Hit Test
|
||||
|
||||
static func collageAttachmentId(
|
||||
at point: CGPoint, attachments: [MessageAttachment], maxWidth: CGFloat
|
||||
) -> String {
|
||||
let spacing: CGFloat = 2
|
||||
let count = attachments.count
|
||||
let x = point.x
|
||||
let y = point.y
|
||||
|
||||
switch count {
|
||||
case 2:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
return attachments[x < half ? 0 : 1].id
|
||||
|
||||
case 3:
|
||||
let rightWidth = maxWidth * 0.34
|
||||
let leftWidth = maxWidth - spacing - rightWidth
|
||||
let totalHeight = min(leftWidth * 1.1, 300)
|
||||
let rightCellHeight = (totalHeight - spacing) / 2
|
||||
if x < leftWidth {
|
||||
return attachments[0].id
|
||||
} else {
|
||||
return attachments[y < rightCellHeight ? 1 : 2].id
|
||||
}
|
||||
|
||||
case 4:
|
||||
let half = (maxWidth - spacing) / 2
|
||||
let cellHeight = min(half * 0.85, 150)
|
||||
let row = y < cellHeight ? 0 : 1
|
||||
let col = x < half ? 0 : 1
|
||||
return attachments[row * 2 + col].id
|
||||
|
||||
case 5:
|
||||
let topCellWidth = (maxWidth - spacing) / 2
|
||||
let bottomCellWidth = (maxWidth - spacing * 2) / 3
|
||||
let topHeight = min(topCellWidth * 0.85, 165)
|
||||
if y < topHeight {
|
||||
return attachments[x < topCellWidth ? 0 : 1].id
|
||||
} else {
|
||||
let col = min(Int(x / (bottomCellWidth + spacing)), 2)
|
||||
return attachments[2 + col].id
|
||||
}
|
||||
|
||||
default:
|
||||
return attachments[0].id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sender Display Name
|
||||
|
||||
@MainActor private static var senderNameCache: [String: String] = [:]
|
||||
|
||||
private func senderDisplayName(for publicKey: String) -> String {
|
||||
if publicKey == currentPublicKey {
|
||||
return "You"
|
||||
}
|
||||
if let cached = Self.senderNameCache[publicKey] {
|
||||
return cached
|
||||
}
|
||||
if publicKey == opponentPublicKey && !opponentTitle.isEmpty {
|
||||
Self.senderNameCache[publicKey] = opponentTitle
|
||||
return opponentTitle
|
||||
}
|
||||
if let dialog = DialogRepository.shared.dialogs[publicKey],
|
||||
!dialog.opponentTitle.isEmpty {
|
||||
Self.senderNameCache[publicKey] = dialog.opponentTitle
|
||||
return dialog.opponentTitle
|
||||
}
|
||||
if publicKey == opponentPublicKey && !opponentUsername.isEmpty {
|
||||
let name = "@\(opponentUsername)"
|
||||
Self.senderNameCache[publicKey] = name
|
||||
return name
|
||||
}
|
||||
return String(publicKey.prefix(8)) + "…"
|
||||
}
|
||||
|
||||
// MARK: - Static Caches
|
||||
|
||||
@MainActor private static var replyBlobCache: [String: [ReplyMessageData]] = [:]
|
||||
|
||||
private func parseReplyBlob(_ blob: String) -> [ReplyMessageData]? {
|
||||
guard !blob.isEmpty else { return nil }
|
||||
if let cached = Self.replyBlobCache[blob] { return cached }
|
||||
guard let data = blob.data(using: .utf8) else { return nil }
|
||||
guard let result = try? JSONDecoder().decode([ReplyMessageData].self, from: data) else { return nil }
|
||||
if Self.replyBlobCache.count > 300 {
|
||||
let keysToRemove = Array(Self.replyBlobCache.keys.prefix(150))
|
||||
for key in keysToRemove { Self.replyBlobCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.replyBlobCache[blob] = result
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor private static var markdownCache: [String: AttributedString] = [:]
|
||||
|
||||
private func parsedMarkdown(_ text: String) -> AttributedString {
|
||||
if let cached = Self.markdownCache[text] {
|
||||
PerformanceLogger.shared.track("markdown.cacheHit")
|
||||
return cached
|
||||
}
|
||||
PerformanceLogger.shared.track("markdown.cacheMiss")
|
||||
|
||||
let withEmoji = EmojiParser.replaceShortcodes(in: text)
|
||||
let result: AttributedString
|
||||
if let parsed = try? AttributedString(
|
||||
markdown: withEmoji,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
result = parsed
|
||||
} else {
|
||||
result = AttributedString(withEmoji)
|
||||
}
|
||||
if Self.markdownCache.count > 500 {
|
||||
let keysToRemove = Array(Self.markdownCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.markdownCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.markdownCache[text] = result
|
||||
return result
|
||||
}
|
||||
|
||||
@MainActor private static var blurHashCache: [String: UIImage] = [:]
|
||||
|
||||
@MainActor
|
||||
static func cachedBlurHash(_ hash: String, width: Int, height: Int) -> UIImage? {
|
||||
let key = "\(hash)_\(width)x\(height)"
|
||||
if let cached = blurHashCache[key] { return cached }
|
||||
guard let image = UIImage.fromBlurHash(hash, width: width, height: height) else { return nil }
|
||||
if blurHashCache.count > 300 {
|
||||
let keysToRemove = Array(blurHashCache.keys.prefix(150))
|
||||
for key in keysToRemove { blurHashCache.removeValue(forKey: key) }
|
||||
}
|
||||
blurHashCache[key] = image
|
||||
return image
|
||||
}
|
||||
|
||||
@MainActor private static var timeCache: [Int64: String] = [:]
|
||||
|
||||
private func messageTime(_ timestamp: Int64) -> String {
|
||||
if let cached = Self.timeCache[timestamp] { return cached }
|
||||
let result = Self.timeFormatter.string(
|
||||
from: Date(timeIntervalSince1970: Double(timestamp) / 1000)
|
||||
)
|
||||
if Self.timeCache.count > 500 {
|
||||
let keysToRemove = Array(Self.timeCache.keys.prefix(250))
|
||||
for key in keysToRemove { Self.timeCache.removeValue(forKey: key) }
|
||||
}
|
||||
Self.timeCache[timestamp] = result
|
||||
return result
|
||||
}
|
||||
|
||||
static let timeFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "HH:mm"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
// MARK: - Static Helpers
|
||||
|
||||
static func isGarbageText(_ text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return true }
|
||||
let validCharacters = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.value != 0xFFFD &&
|
||||
scalar.value > 0x1F &&
|
||||
scalar.value != 0x7F &&
|
||||
!CharacterSet.controlCharacters.contains(scalar)
|
||||
}
|
||||
return validCharacters.isEmpty
|
||||
}
|
||||
|
||||
static func isValidCaption(_ text: String) -> Bool {
|
||||
let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.isEmpty { return false }
|
||||
if text == " " { return false }
|
||||
if isGarbageText(text) { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
static func partitionAttachments(
|
||||
_ attachments: [MessageAttachment]
|
||||
) -> (images: [MessageAttachment], others: [MessageAttachment]) {
|
||||
var images: [MessageAttachment] = []
|
||||
var others: [MessageAttachment] = []
|
||||
for att in attachments {
|
||||
if att.type == .image { images.append(att) }
|
||||
else { others.append(att) }
|
||||
}
|
||||
return (images, others)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user