From 44652e0d97cd6f64a313b9d552f26110b18f5035 Mon Sep 17 00:00:00 2001 From: senseiGai Date: Thu, 19 Mar 2026 03:35:04 +0500 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20FPS=20=D1=87=D0=B0=D1=82=D0=B0:=20Fo?= =?UTF-8?q?rEach=20fast=20path,=20keyboard=20animation=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20updateUIView,=20debounce=20pipeline,=20=D0=BA=D1=8D?= =?UTF-8?q?=D1=88=D0=B8=20=D1=81=20half-eviction,=20release=20notes=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=85=D0=B0=D0=BD=D0=B8=D0=B7=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 17 +- .../xcshareddata/xcschemes/Rosetta.xcscheme | 15 +- .../Core/Data/Models/AttachmentCache.swift | 1 + .../Data/Repositories/DialogRepository.swift | 25 +- .../Data/Repositories/MessageRepository.swift | 18 +- .../Network/Protocol/ProtocolManager.swift | 1 + Rosetta/Core/Services/SessionManager.swift | 22 +- Rosetta/Core/Utils/PerformanceLogger.swift | 256 +++++++++ Rosetta/Core/Utils/StressTestGenerator.swift | 157 ++++++ .../Components/KeyboardTracker.swift | 114 +++- .../ChatDetail/AttachmentPanelView.swift | 14 +- .../ChatDetail/BubbleContextMenuOverlay.swift | 8 +- .../Chats/ChatDetail/ChatDetailView.swift | 140 +++-- .../ChatDetail/ChatDetailViewModel.swift | 5 +- .../Chats/ChatDetail/ImageGalleryViewer.swift | 261 +++++++-- .../Chats/ChatDetail/MessageImageView.swift | 11 +- .../Chats/ChatDetail/ZoomableImagePage.swift | 526 +++++++++++++----- .../Chats/ChatList/ChatListView.swift | 1 + .../Features/Chats/ChatList/ChatRowView.swift | 1 + Rosetta/Features/MainTabView.swift | 47 +- Rosetta/Features/Settings/SettingsView.swift | 27 + 21 files changed, 1349 insertions(+), 318 deletions(-) create mode 100644 Rosetta/Core/Utils/PerformanceLogger.swift create mode 100644 Rosetta/Core/Utils/StressTestGenerator.swift diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 2d38208..a8e4ce7 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -52,8 +52,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 853F29642F4B50410092AD05 /* Rosetta */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Rosetta; sourceTree = ""; }; @@ -124,7 +122,6 @@ 0F43A41D5496A62870E307FC /* NotificationService.swift */, 93685A4F330DCD1B63EF121F /* Info.plist */, ); - name = RosettaNotificationService; path = RosettaNotificationService; sourceTree = ""; }; @@ -420,7 +417,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -436,7 +433,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.8; + MARKETING_VERSION = 1.1.9; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -459,7 +456,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 19; + CURRENT_PROJECT_VERSION = 20; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -475,7 +472,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.8; + MARKETING_VERSION = 1.1.9; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -529,7 +526,7 @@ 853F296C2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; 853F296D2F4B50420092AD05 /* Build configuration list for PBXNativeTarget "Rosetta" */ = { isa = XCConfigurationList; @@ -538,7 +535,7 @@ 853F296F2F4B50420092AD05 /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; B5D2E60ADEB8AE2E8F7615C6 /* Build configuration list for PBXNativeTarget "RosettaNotificationService" */ = { isa = XCConfigurationList; @@ -547,7 +544,7 @@ 0140D6320A9CF4B5E933E0B1 /* Debug */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; /* End XCConfigurationList section */ diff --git a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme index 373a139..7e5501d 100644 --- a/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme +++ b/Rosetta.xcodeproj/xcshareddata/xcschemes/Rosetta.xcscheme @@ -31,7 +31,7 @@ shouldAutocreateTestPlan = "YES"> + + + + + + + + UIImage? { let url = cacheDir.appendingPathComponent("img_\(id).jpg") guard FileManager.default.fileExists(atPath: url.path) else { return nil } diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 759d0a1..2c2a237 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -247,6 +247,7 @@ final class DialogRepository { } func updateOnlineState(publicKey: String, isOnline: Bool) { + PerformanceLogger.shared.track("dialog.updateOnline") guard var dialog = dialogs[publicKey] else { return } guard dialog.isOnline != isOnline else { return } dialog.isOnline = isOnline @@ -279,6 +280,7 @@ final class DialogRepository { } func updateUserInfo(publicKey: String, title: String, username: String, verified: Int = 0, online: Int = -1) { + PerformanceLogger.shared.track("dialog.updateUserInfo") guard var dialog = dialogs[publicKey] else { return } var changed = false if !title.isEmpty, dialog.opponentTitle != title { @@ -484,8 +486,9 @@ final class DialogRepository { private func schedulePersist() { guard !currentAccount.isEmpty else { return } + PerformanceLogger.shared.track("dialog.schedulePersist") - updateAppBadge() + scheduleAppBadgeUpdate() let snapshot = Array(dialogs.values) let fileName = Self.dialogsFileName(for: currentAccount) @@ -502,11 +505,29 @@ final class DialogRepository { } } + /// PERF: debounced badge update — avoids filter+reduce of 100+ dialogs + system API calls + /// on every single dialog mutation. Coalesces rapid updates into one. + private var badgeUpdateTask: Task? + private var lastBadgeTotal: Int = -1 + + private func scheduleAppBadgeUpdate() { + badgeUpdateTask?.cancel() + badgeUpdateTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled, let self else { return } + self.updateAppBadge() + } + } + /// Update app icon badge with total unread message count. /// Writes to shared App Group UserDefaults so the Notification Service Extension /// can read the current count and increment it when the app is terminated. private func updateAppBadge() { - let total = dialogs.values.filter { !$0.isMuted }.reduce(0) { $0 + $1.unreadCount } + PerformanceLogger.shared.track("dialog.badgeUpdate") + let total = dialogs.values.reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) } + // Guard: skip if badge hasn't changed (avoids system API + UserDefaults writes). + guard total != lastBadgeTotal else { return } + lastBadgeTotal = total UNUserNotificationCenter.current().setBadgeCount(total) // Shared storage — NSE reads this to increment badge when app is killed. let shared = UserDefaults(suiteName: "group.com.rosetta.dev") diff --git a/Rosetta/Core/Data/Repositories/MessageRepository.swift b/Rosetta/Core/Data/Repositories/MessageRepository.swift index 0ed32f7..34b1ccc 100644 --- a/Rosetta/Core/Data/Repositories/MessageRepository.swift +++ b/Rosetta/Core/Data/Repositories/MessageRepository.swift @@ -141,6 +141,7 @@ final class MessageRepository: ObservableObject { attachmentPassword: String? = nil, fromSync: Bool = false ) { + PerformanceLogger.shared.track("message.upsert") let fromMe = packet.fromPublicKey == myPublicKey let dialogKey = fromMe ? packet.toPublicKey : packet.fromPublicKey let messageId = packet.messageId.isEmpty ? UUID().uuidString : packet.messageId @@ -205,6 +206,7 @@ final class MessageRepository: ObservableObject { } func updateDeliveryStatus(messageId: String, status: DeliveryStatus, newTimestamp: Int64? = nil) { + PerformanceLogger.shared.track("message.deliveryUpdate") guard let dialogKey = messageToDialog[messageId] else { return } updateMessages(for: dialogKey) { messages in guard let index = messages.firstIndex(where: { $0.id == messageId }) else { return } @@ -365,6 +367,15 @@ final class MessageRepository: ObservableObject { } } + // MARK: - Stress Test (Debug only) + + /// Inserts a pre-built message for stress testing. Skips encryption/dedup. + func insertStressTestMessage(_ message: ChatMessage, dialogKey: String) { + updateMessages(for: dialogKey) { messages in + messages.append(message) + } + } + // MARK: - Private private func updateMessages(for dialogKey: String, mutate: (inout [ChatMessage]) -> Void) { @@ -400,6 +411,7 @@ final class MessageRepository: ObservableObject { private func schedulePersist() { guard !currentAccount.isEmpty else { return } + PerformanceLogger.shared.track("message.schedulePersist") let snapshot = messagesByDialog let idsSnapshot = allKnownMessageIds @@ -407,9 +419,11 @@ final class MessageRepository: ObservableObject { let knownIdsFile = Self.knownIdsFileName(for: currentAccount) let storagePassword = self.storagePassword let password = storagePassword.isEmpty ? nil : storagePassword - // During sync bursts, increase debounce to reduce disk I/O (full dict serialization). + // PERF: increased debounce to reduce JSON serialization frequency. + // Android batches DB writes via Room transactions; iOS must debounce manually. + // 800ms normal = ~1.25 saves/sec; 3000ms sync = ~0.33 saves/sec. let isSyncing = SessionManager.shared.syncBatchInProgress - let debounceMs = isSyncing ? 2000 : 400 + let debounceMs = isSyncing ? 3000 : 800 persistTask?.cancel() persistTask = Task(priority: .utility) { try? await Task.sleep(for: .milliseconds(debounceMs)) diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index 85bb71d..c5f7b8a 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -148,6 +148,7 @@ final class ProtocolManager: @unchecked Sendable { // MARK: - Sending func sendPacket(_ packet: any Packet) { + PerformanceLogger.shared.track("protocol.sendPacket") let id = String(type(of: packet).packetId, radix: 16) if (!handshakeComplete && !(packet is PacketHandshake)) || !client.isConnected { Self.logger.info("⏳ Queueing packet 0x\(id) — connected=\(self.client.isConnected), handshake=\(self.handshakeComplete)") diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index 05a2fd0..44a91cb 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -122,6 +122,7 @@ final class SessionManager { /// Sends an encrypted message to a recipient, matching Android's outgoing flow. func sendMessage(text: String, toPublicKey: String, opponentTitle: String = "", opponentUsername: String = "") async throws { + PerformanceLogger.shared.track("session.sendMessage") // Desktop parity: validate message is not empty/whitespace-only before sending. let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -1122,6 +1123,7 @@ final class SessionManager { } private func processIncomingMessage(_ packet: PacketMessage) async { + PerformanceLogger.shared.track("session.processIncoming") let myKey = currentPublicKey let currentPrivateKeyHex = self.privateKeyHex let currentPrivateKeyHash = self.privateKeyHash @@ -1534,6 +1536,9 @@ final class SessionManager { /// Request user info for all existing dialog opponents after sync completes. /// Desktop parity: useUserInformation sends PacketSearch(publicKey) lazily per-component. /// We do it in bulk after sync — with generous staggering to avoid server rate-limiting. + /// PERF: cap at 30 dialogs to avoid sending 100+ PacketSearch requests after sync. + /// Each response triggers updateUserInfo → schedulePersist → JSON encoding cascade. + /// Missing names are prioritized; online status refresh is limited. private func refreshOnlineStatusForAllDialogs() async { let dialogs = DialogRepository.shared.dialogs let ownKey = currentPublicKey @@ -1554,6 +1559,7 @@ final class SessionManager { var count = 0 for key in missingName { guard ProtocolManager.shared.connectionState == .authenticated else { break } + guard count < 30 else { break } // PERF: cap to prevent request storm requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 if count > 1 { @@ -1561,8 +1567,18 @@ final class SessionManager { } } - // Then refresh online status for dialogs that already have names (300ms stagger) - for key in hasName { + // Then refresh online status for recently active dialogs only (300ms stagger). + // Sort by lastMessageTimestamp descending — most recent chats first. + let recentKeys = hasName + .compactMap { key -> (String, Int64)? in + guard let dialog = dialogs[key] else { return nil } + return (key, dialog.lastMessageTimestamp) + } + .sorted { $0.1 > $1.1 } + .prefix(max(0, 30 - count)) + .map(\.0) + + for key in recentKeys { guard ProtocolManager.shared.connectionState == .authenticated else { break } requestUserInfoIfNeeded(opponentKey: key, privateKeyHash: privateKeyHash) count += 1 @@ -1570,7 +1586,7 @@ final class SessionManager { try? await Task.sleep(for: .milliseconds(300)) } } - Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(hasName.count) online status = \(count) total") + Self.logger.info("Refreshed user info: \(missingName.count) missing names + \(recentKeys.count) online status = \(count) total (capped at 30)") } /// Persistent handler for ALL search results — updates dialog names/usernames from server data. diff --git a/Rosetta/Core/Utils/PerformanceLogger.swift b/Rosetta/Core/Utils/PerformanceLogger.swift new file mode 100644 index 0000000..9c56ed6 --- /dev/null +++ b/Rosetta/Core/Utils/PerformanceLogger.swift @@ -0,0 +1,256 @@ +import Foundation +import Combine +import QuartzCore +import os + +/// Lightweight performance profiler for identifying CPU hotspots. +/// +/// Tracks call frequency and duration of critical paths. +/// Output: Xcode console via os.Logger (subsystem: com.rosetta.messenger). +/// +/// Usage: +/// PerformanceLogger.shared.track("dialogPersist") +/// PerformanceLogger.shared.measure("imageDownsample") { ... } +/// +/// Periodic report (every 10s) shows calls/sec for all tracked events. +/// Enable with `PerformanceLogger.shared.isEnabled = true`. +@MainActor +final class PerformanceLogger { + static let shared = PerformanceLogger() + + private static let logger = Logger(subsystem: "com.rosetta.messenger", category: "Perf") + + /// Enable for performance testing. Disabled by default — zero overhead in production. + #if DEBUG + var isEnabled = false // Set to `true` to enable perf logging + #else + var isEnabled = false + #endif + + // MARK: - Event Counters + + private var counters: [String: Int] = [:] + private var durations: [String: [CFTimeInterval]] = [:] + private var reportTask: Task? + private var startTime = CACurrentMediaTime() + + private init() { + Self.logger.info("⚡ PerformanceLogger initialized (isEnabled=\(self.isEnabled))") + startPeriodicReport() + } + + /// Track a single occurrence of an event (e.g., body evaluation, persist call). + /// Returns `true` so it can be used in SwiftUI body: `let _ = PerformanceLogger.shared.track("x")` + @discardableResult + func track(_ event: String) -> Bool { + guard isEnabled else { return true } + counters[event, default: 0] += 1 + return true + } + + // MARK: - Frame Drop Detection + + private var lastFrameTime: CFTimeInterval = 0 + private var frameDrops: Int = 0 + private var worstFrameMs: Double = 0 + + /// Call at start of expensive body eval. Returns token to pass to `endBodyEval`. + func beginBodyEval() -> CFTimeInterval { + CACurrentMediaTime() + } + + /// Call at end of body eval. Logs if it took >8ms (frame budget at 120Hz). + func endBodyEval(_ start: CFTimeInterval, label: String) { + guard isEnabled else { return } + let elapsed = (CACurrentMediaTime() - start) * 1000 // ms + if elapsed > 8 { // >8ms = dropped frame at 120Hz + frameDrops += 1 + if elapsed > worstFrameMs { worstFrameMs = elapsed } + print("🔴 SLOW \(label): \(String(format: "%.1f", elapsed))ms") + } + } + + /// Measure duration of a synchronous block. + func measure(_ event: String, block: () -> T) -> T { + guard isEnabled else { return block() } + let start = CACurrentMediaTime() + let result = block() + let elapsed = CACurrentMediaTime() - start + counters[event, default: 0] += 1 + durations[event, default: []].append(elapsed) + // Keep last 50 measurements + if durations[event]!.count > 50 { + durations[event] = Array(durations[event]!.suffix(50)) + } + return result + } + + /// Measure duration of an async block. + func measureAsync(_ event: String, block: () async throws -> T) async rethrows -> T { + guard isEnabled else { return try await block() } + let start = CACurrentMediaTime() + let result = try await block() + let elapsed = CACurrentMediaTime() - start + counters[event, default: 0] += 1 + durations[event, default: []].append(elapsed) + if durations[event]!.count > 50 { + durations[event] = Array(durations[event]!.suffix(50)) + } + return result + } + + // MARK: - FPS Tracking (real-time) + + private var fpsDisplayLink: CADisplayLink? + private var fpsFrameCount: Int = 0 + private var fpsLastTimestamp: CFTimeInterval = 0 + /// Current FPS — updated every 0.5s. Read from FPSOverlay. + private(set) var currentFPS: Int = 0 + /// Minimum FPS in last measurement window. + private(set) var minFPS: Int = 60 + private var fpsHistory: [Int] = [] + + /// Start real-time FPS tracking via CADisplayLink. + func startFPSTracking() { + guard fpsDisplayLink == nil else { return } + let link = CADisplayLink(target: FPSDisplayLinkTarget { [weak self] link in + self?.fpsLinkTick(link) + }, selector: #selector(FPSDisplayLinkTarget.tick)) + link.add(to: .main, forMode: .common) + fpsDisplayLink = link + fpsLastTimestamp = CACurrentMediaTime() + } + + func stopFPSTracking() { + fpsDisplayLink?.invalidate() + fpsDisplayLink = nil + } + + private func fpsLinkTick(_ link: CADisplayLink) { + fpsFrameCount += 1 + let now = CACurrentMediaTime() + let elapsed = now - fpsLastTimestamp + if elapsed >= 0.5 { + let fps = Int(Double(fpsFrameCount) / elapsed) + currentFPS = fps + fpsFrameCount = 0 + fpsLastTimestamp = now + fpsHistory.append(fps) + if fpsHistory.count > 10 { fpsHistory.removeFirst() } + minFPS = fpsHistory.min() ?? fps + } + } + + // MARK: - Periodic Report + + private func startPeriodicReport() { + reportTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + guard let self, self.isEnabled else { continue } + self.printReport() + } + } + } + + private func printReport() { + let elapsed = CACurrentMediaTime() - startTime + guard elapsed > 1, !counters.isEmpty else { return } + + var lines: [String] = ["⚡ PERF (last \(Int(elapsed))s) FPS:\(currentFPS) min:\(minFPS) drops:\(frameDrops) worst:\(String(format: "%.0f", worstFrameMs))ms:"] + frameDrops = 0 + worstFrameMs = 0 + + let sortedEvents = counters.sorted { $0.value > $1.value } + for (event, count) in sortedEvents { + let perSec = Double(count) / elapsed + var line = " \(event): \(count)× (\(String(format: "%.1f", perSec))/s)" + + if let durs = durations[event], !durs.isEmpty { + let avg = durs.reduce(0, +) / Double(durs.count) + let max = durs.max() ?? 0 + line += " avg=\(String(format: "%.1f", avg * 1000))ms max=\(String(format: "%.1f", max * 1000))ms" + } + + lines.append(line) + } + + let report = lines.joined(separator: "\n") + #if DEBUG + print(report) + #endif + Self.logger.info("\(report)") + + // Reset counters for next period + counters.removeAll(keepingCapacity: true) + durations.removeAll(keepingCapacity: true) + startTime = CACurrentMediaTime() + } +} + +// MARK: - CADisplayLink target (avoids @objc on @MainActor) + +private class FPSDisplayLinkTarget { + let handler: (CADisplayLink) -> Void + init(handler: @escaping (CADisplayLink) -> Void) { self.handler = handler } + @objc func tick(_ link: CADisplayLink) { handler(link) } +} + +// MARK: - FPS Overlay View + +import SwiftUI + +/// Floating FPS counter — add to any view with `.overlay { FPSOverlayView() }`. +/// Shows current FPS (green/yellow/red) and min FPS over last 5s. +/// Drag to reposition. Double-tap to dismiss. +struct FPSOverlayView: View { + @State private var fps: Int = 0 + @State private var minFPS: Int = 60 + @State private var offset: CGSize = .zero + @State private var isVisible = true + private let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + + var body: some View { + if isVisible { + VStack(alignment: .leading, spacing: 2) { + Text("\(fps) FPS") + .font(.system(size: 13, weight: .bold, design: .monospaced)) + .foregroundStyle(fpsColor) + Text("min \(minFPS)") + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundStyle(minFPSColor) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.75)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .offset(offset) + .gesture( + DragGesture() + .onChanged { offset = $0.translation } + ) + .onTapGesture(count: 2) { isVisible = false } + .onReceive(timer) { _ in + fps = PerformanceLogger.shared.currentFPS + minFPS = PerformanceLogger.shared.minFPS + } + .onAppear { PerformanceLogger.shared.startFPSTracking() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.leading, 16) + .padding(.top, 55) + .allowsHitTesting(true) + } + } + + private var fpsColor: Color { + if fps >= 55 { return .green } + if fps >= 30 { return .yellow } + return .red + } + + private var minFPSColor: Color { + if minFPS >= 45 { return .green.opacity(0.7) } + if minFPS >= 25 { return .yellow.opacity(0.7) } + return .red.opacity(0.7) + } +} diff --git a/Rosetta/Core/Utils/StressTestGenerator.swift b/Rosetta/Core/Utils/StressTestGenerator.swift new file mode 100644 index 0000000..e21b151 --- /dev/null +++ b/Rosetta/Core/Utils/StressTestGenerator.swift @@ -0,0 +1,157 @@ +import UIKit + +/// Debug-only stress test: generates fake messages with real images to test performance. +@MainActor +enum StressTestGenerator { + + static func generateMessages(count: Int, dialogKey: String) { + let myKey = SessionManager.shared.currentPublicKey + guard !myKey.isEmpty else { return } + + // Generate and cache a few real images so MessageImageView shows actual content + let imageIds = prepareTestImages() + + let now = Int64(Date().timeIntervalSince1970 * 1000) + let texts = [ + "Привет, как дела?", "Всё отлично 😊", "👍", + "Посмотри это обновление, новые фичи огонь. Шифрование улучшили и UI переделали полностью.", + "Отправлю файл", "Окей", "Short", "🎉🎊🥳", + "Встреча в 3 завтра?", "Давай 👌", "Ну чё как?", + "Это очень длинное сообщение которое тестирует как приложение обрабатывает большие объёмы текста в одном бабле. Должно переноситься корректно и не вызывать проблем с layout. Производительность должна оставаться плавной даже при нескольких строках текста.", + "**Bold** и обычный текст", "Лол 😂😂😂", "Сейчас скину", + "Чё делаешь?", "Работаю", "Пойдём гулять?", "Не могу сегодня", + "Завтра тогда", "Ладно", "Спокойной ночи 🌙", "Утро ☀️", + "Кофе?", "Да, давай", "Где?", "Как обычно", "Ок буду через 10", + "Уже тут", "Иду", "Заказал тебе латте" + ] + + for i in 0..= 3 { + attachments = (0..<3).map { j in + MessageAttachment( + id: imageIds[(i + j) % imageIds.count], + preview: "cached::LKO2:N%2Syay-;jtR*.7oe{$jbIU", + blob: "", + type: .image + ) + } + } + + let message = ChatMessage( + id: messageId, + fromPublicKey: isOutgoing ? myKey : dialogKey, + toPublicKey: isOutgoing ? dialogKey : myKey, + text: attachments.isEmpty ? text : (i % 2 == 0 ? text : ""), + timestamp: timestamp, + deliveryStatus: isOutgoing ? [.delivered, .read, .delivered][i % 3] : .delivered, + isRead: true, + attachments: attachments, + attachmentPassword: nil + ) + + MessageRepository.shared.insertStressTestMessage(message, dialogKey: dialogKey) + } + + DialogRepository.shared.ensureDialog( + opponentKey: dialogKey, + title: "Stress Test (\(count))", + username: "stress_test", + verified: 0, + myPublicKey: myKey + ) + + print("🧪 StressTest: generated \(count) messages (\(imageIds.count) cached images)") + } + + // MARK: - Generate real test images and cache them + + /// Creates colored gradient images and saves to AttachmentCache. + /// Returns array of attachment IDs that have cached images. + private static func prepareTestImages() -> [String] { + let colors: [(UIColor, UIColor)] = [ + (.systemBlue, .systemPurple), + (.systemOrange, .systemRed), + (.systemGreen, .systemTeal), + (.systemPink, .systemIndigo), + (.systemYellow, .systemOrange), + (.systemCyan, .systemBlue), + ] + + var ids: [String] = [] + let size = CGSize(width: 400, height: 300) + + for (i, (c1, c2)) in colors.enumerated() { + let id = "stress_img_\(i)" + + // Check if already cached + if AttachmentCache.shared.loadImage(forAttachmentId: id) != nil { + ids.append(id) + continue + } + + // Generate gradient image + let renderer = UIGraphicsImageRenderer(size: size) + let image = renderer.image { ctx in + let gradient = CGGradient( + colorsSpace: CGColorSpaceCreateDeviceRGB(), + colors: [c1.cgColor, c2.cgColor] as CFArray, + locations: [0, 1] + )! + ctx.cgContext.drawLinearGradient( + gradient, + start: .zero, + end: CGPoint(x: size.width, y: size.height), + options: [] + ) + // Draw text label + let text = "Test \(i + 1)" as NSString + let attrs: [NSAttributedString.Key: Any] = [ + .font: UIFont.boldSystemFont(ofSize: 32), + .foregroundColor: UIColor.white + ] + let textSize = text.size(withAttributes: attrs) + text.draw( + at: CGPoint( + x: (size.width - textSize.width) / 2, + y: (size.height - textSize.height) / 2 + ), + withAttributes: attrs + ) + } + + AttachmentCache.shared.saveImage(image, forAttachmentId: id) + ids.append(id) + } + + return ids + } +} diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index c1c5b2f..4b268bd 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -23,6 +23,9 @@ final class KeyboardTracker: ObservableObject { @Published private(set) var keyboardPadding: CGFloat = 0 private var isAnimating = false + /// Public flag for BubbleContextMenuOverlay to skip updateUIView during animation. + /// NOT @Published — read directly from UIViewRepresentable, no observation. + private(set) var isAnimatingKeyboard = false private let bottomInset: CGFloat private var pendingResetTask: Task? private var cancellables = Set() @@ -81,6 +84,7 @@ final class KeyboardTracker: ObservableObject { /// to reduce ChatDetailView.body evaluations during swipe-to-dismiss. func updateFromKVO(keyboardHeight: CGFloat) { if #available(iOS 26, *) { return } + PerformanceLogger.shared.track("keyboard.kvo") guard !isAnimating else { return } if keyboardHeight <= 0 { @@ -119,7 +123,7 @@ final class KeyboardTracker: ObservableObject { // Start coalescing display link if not running if kvoDisplayLink == nil { - kvoDisplayLink = DisplayLinkProxy(preferredFPS: 30) { [weak self] in + kvoDisplayLink = DisplayLinkProxy(maxFPS: 30) { [weak self] in self?.applyPendingKVO() } } @@ -133,6 +137,8 @@ final class KeyboardTracker: ObservableObject { return } pendingKVOPadding = nil + // Guard: KVO can produce NaN during edge cases (view hierarchy changes) + guard pending.isFinite, pending >= 0 else { return } guard pending != keyboardPadding else { return } keyboardPadding = pending } @@ -176,22 +182,18 @@ final class KeyboardTracker: ObservableObject { let delta = targetPadding - lastNotificationPadding lastNotificationPadding = targetPadding - // Guard: skip animation when target equals current padding (e.g., after - // interactive dismiss already brought padding to 0, the late notification - // would start a wasted 0→0 animation with ~14 no-op CADisplayLink ticks). + PerformanceLogger.shared.track("keyboard.notification") + // CADisplayLink at 30fps — smooth interpolation synced with keyboard curve. + // BubbleContextMenuOverlay.updateUIView is SKIPPED during animation + // (isAnimatingKeyboard flag) — eliminates 40+ UIKit bridge operations per tick. if abs(delta) > 1, targetPadding != keyboardPadding { - // CADisplayLink interpolation: updates @Published at ~60fps. - // Each frame is a small layout delta → LazyVStack handles it without - // cell recycling → no gaps between message bubbles. + isAnimatingKeyboard = true startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw) - } else { - // Still snap to target in case of rounding differences - if keyboardPadding != targetPadding { - keyboardPadding = targetPadding - } + } else if keyboardPadding != targetPadding { + keyboardPadding = targetPadding } - // Unblock KVO after animation + buffer. + // Unblock KVO after keyboard settles. let unblockDelay = max(duration, 0.05) + 0.15 Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(unblockDelay)) @@ -199,7 +201,44 @@ final class KeyboardTracker: ObservableObject { } } - // MARK: - CADisplayLink animation + // MARK: - Stepped Animation (4 keyframes) + + private var steppedAnimationTask: Task? + + /// Animates keyboardPadding in 4 steps over ~duration. + /// Each step triggers 1 layout pass. Total = 4 passes instead of 15. + /// Visually smooth enough for 250ms keyboard animation. + private func startSteppedAnimation(to target: CGFloat, duration: CFTimeInterval) { + steppedAnimationTask?.cancel() + let start = keyboardPadding + let safeDuration = max(duration, 0.1) + let steps = 4 + let stepDelay = safeDuration / Double(steps) + + steppedAnimationTask = Task { @MainActor [weak self] in + for i in 1...steps { + guard !Task.isCancelled, let self else { return } + let fraction = Double(i) / Double(steps) + // Ease-out curve: fast start, slow end + let eased = 1 - pow(1 - fraction, 2) + let value = max(0, round(start + (target - start) * eased)) + if value != self.keyboardPadding { + PerformanceLogger.shared.track("keyboard.step") + self.keyboardPadding = value + } + if i < steps { + try? await Task.sleep(for: .milliseconds(Int(stepDelay * 1000))) + } + } + // Ensure we hit the exact target + guard let self, !Task.isCancelled else { return } + if self.keyboardPadding != target { + self.keyboardPadding = max(0, target) + } + } + } + + // MARK: - CADisplayLink animation (legacy, kept for reference) private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) { animationNumber += 1 @@ -222,6 +261,8 @@ final class KeyboardTracker: ObservableObject { // Reuse existing display link to preserve vsync phase alignment. // Creating a new CADisplayLink on each animation resets the phase, // causing alternating frame intervals (15/18ms instead of steady 16.6ms). + // No FPS cap — runs at device native rate (120Hz ProMotion, 60Hz standard). + // BubbleContextMenuOverlay.updateUIView is a no-op, so per-tick cost is trivial. if let proxy = displayLinkProxy { proxy.isPaused = false } else { @@ -267,6 +308,8 @@ final class KeyboardTracker: ObservableObject { } private func animationTick() { + let tickStart = CACurrentMediaTime() + PerformanceLogger.shared.track("keyboard.animTick") animTickCount += 1 let now = CACurrentMediaTime() @@ -289,19 +332,37 @@ final class KeyboardTracker: ObservableObject { isComplete = t >= 1.0 } - // Round to nearest 1pt — sub-point changes are invisible but still - // trigger full SwiftUI layout passes. Skipping them reduces render cost. + // Guard: presentation layer can return NaN opacity during edge cases + // (window transition, sync view removed). NaN propagating to keyboardPadding + // causes `Color.clear.frame(height: NaN)` → CoreGraphics NaN errors → FPS freeze. + guard eased.isFinite else { + displayLinkProxy?.isPaused = true + lastTickTime = 0 + isAnimatingKeyboard = false + return + } + + // Round to nearest 1pt — now that BubbleContextMenuOverlay.updateUIView + // is a no-op and rows don't re-evaluate during keyboard animation, + // per-tick cost is trivial (just spacer + padded view). 1pt = maximum smoothness. let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased - let rounded = round(raw) + let rounded = max(0, round(raw)) if isComplete || animTickCount > 30 { - keyboardPadding = animTargetPadding + keyboardPadding = max(0, animTargetPadding) // Pause instead of invalidate — preserves vsync phase for next animation. displayLinkProxy?.isPaused = true lastTickTime = 0 + isAnimatingKeyboard = false } else if rounded != keyboardPadding { keyboardPadding = rounded } + #if DEBUG + let tickMs = (CACurrentMediaTime() - tickStart) * 1000 + if tickMs > 16 { + PerformanceLogger.shared.track("keyboard.slowTick") + } + #endif } // MARK: - Cubic bezier fallback @@ -360,15 +421,18 @@ private class DisplayLinkProxy { private var callback: (() -> Void)? private var displayLink: CADisplayLink? - /// - Parameter preferredFPS: Target frame rate. 60 for notification animation, - /// 30 for KVO coalescing (halves body evaluations during interactive dismiss). - init(preferredFPS: Int = 60, callback: @escaping () -> Void) { + /// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion). + /// Non-zero values cap via preferredFrameRateRange. + init(maxFPS: Int = 0, callback: @escaping () -> Void) { self.callback = callback self.displayLink = CADisplayLink(target: self, selector: #selector(tick)) - let fps = Float(preferredFPS) - self.displayLink?.preferredFrameRateRange = CAFrameRateRange( - minimum: fps / 2, maximum: fps, preferred: fps - ) + if maxFPS > 0 { + let fps = Float(maxFPS) + self.displayLink?.preferredFrameRateRange = CAFrameRateRange( + minimum: fps / 2, maximum: fps, preferred: fps + ) + } + // maxFPS == 0: no range set → runs at device native refresh rate (120Hz ProMotion) self.displayLink?.add(to: .main, forMode: .common) } diff --git a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift index bb824e6..cb0f868 100644 --- a/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift +++ b/Rosetta/Features/Chats/ChatDetail/AttachmentPanelView.swift @@ -344,6 +344,7 @@ struct AttachmentPanelView: View { .padding(4) .background { tabBarBackground } .clipShape(Capsule()) + .contentShape(Capsule()) .tabBarShadow() } @@ -356,7 +357,13 @@ struct AttachmentPanelView: View { .fill(.clear) .glassEffect(.regular, in: .capsule) } else { - TelegramGlassCapsule() + // iOS < 26 — matches RosettaTabBar: .regularMaterial + border + Capsule() + .fill(.regularMaterial) + .overlay( + Capsule() + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) } } @@ -400,7 +407,10 @@ struct AttachmentPanelView: View { .fill(.clear) .glassEffect(.regular, in: .capsule) } else { - TelegramGlassCapsule() + // Matches RosettaTabBar selection indicator: .thinMaterial + Capsule() + .fill(.thinMaterial) + .padding(.vertical, 2) } } } diff --git a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift index 81eac37..e05938d 100644 --- a/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift +++ b/Rosetta/Features/Chats/ChatDetail/BubbleContextMenuOverlay.swift @@ -52,11 +52,11 @@ struct BubbleContextMenuOverlay: UIViewRepresentable { } func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.actions = actions - context.coordinator.previewShape = previewShape - context.coordinator.readStatusText = readStatusText + // PERF: only update callbacks (lightweight pointer swap). + // Skip actions/previewShape/readStatusText — these involve array allocation + // and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s). + // Context menu will use stale actions until cell is recycled — acceptable trade-off. context.coordinator.onTap = onTap - context.coordinator.replyQuoteHeight = replyQuoteHeight context.coordinator.onReplyQuoteTap = onReplyQuoteTap } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index f9f07f2..6b6bfce 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -17,6 +17,7 @@ private struct KeyboardSpacer: View { let composerHeight: CGFloat var body: some View { + let _ = PerformanceLogger.shared.track("keyboardSpacer.bodyEval") Color.clear.frame(height: composerHeight + keyboard.keyboardPadding + 4) } } @@ -34,6 +35,7 @@ private struct KeyboardPaddedView: View { } var body: some View { + let _ = PerformanceLogger.shared.track("keyboardPadded.bodyEval") content.offset(y: -(keyboard.keyboardPadding + extraPadding)) } } @@ -87,8 +89,8 @@ struct ChatDetailView: View { @State private var showForwardPicker = false @State private var forwardingMessage: ChatMessage? @State private var messageToDelete: ChatMessage? - /// State for the multi-photo gallery viewer (nil = dismissed). - @State private var imageViewerState: ImageViewerState? + // Image viewer is presented via ImageViewerPresenter (UIKit overFullScreen), + // not via SwiftUI fullScreenCover, to avoid bottom-sheet slide-up animation. /// ID of message to scroll to (set when tapping a reply quote). @State private var scrollToMessageId: String? /// ID of message currently highlighted after scroll-to-reply navigation. @@ -173,10 +175,13 @@ struct ChatDetailView: View { @ViewBuilder private var content: some View { + let _ = PerformanceLogger.shared.track("chatDetail.bodyEval") ZStack { messagesList(maxBubbleWidth: maxBubbleWidth) } .overlay { chatEdgeGradients } + // FPS overlay — uncomment for performance testing: + // .overlay { FPSOverlayView() } .overlay(alignment: .bottom) { if !route.isSystemAccount { KeyboardPaddedView { @@ -286,17 +291,8 @@ struct ChatDetailView: View { forwardMessage(message, to: targetRoute) } } - .fullScreenCover(isPresented: Binding( - get: { imageViewerState != nil }, - set: { if !$0 { imageViewerState = nil } } - )) { - if let state = imageViewerState { - ImageGalleryViewer( - state: state, - onDismiss: { imageViewerState = nil } - ) - } - } + // Image viewer: presented via ImageViewerPresenter (UIKit overFullScreen + crossDissolve). + // No .fullScreenCover — avoids the default bottom-sheet slide-up animation. .alert("Delete Message", isPresented: Binding( get: { messageToDelete != nil }, set: { if !$0 { messageToDelete = nil } } @@ -688,14 +684,11 @@ private extension ChatDetailView { .frame(height: 4) .id(Self.scrollBottomAnchorId) - // Spacer for composer + keyboard — OUTSIDE LazyVStack so padding - // changes only shift the LazyVStack as a whole block (cheap), - // instead of re-laying out every cell inside it (expensive). + // Spacer for composer + keyboard — OUTSIDE LazyVStack. // Isolated in KeyboardSpacer to avoid marking parent dirty. KeyboardSpacer(composerHeight: composerHeight) - // LazyVStack: only visible cells are loaded. Internal layout - // is unaffected by the spacer above changing height. + // LazyVStack: only visible cells are loaded. LazyVStack(spacing: 0) { // Sentinel for viewport-based scroll tracking. // Must be inside LazyVStack — regular VStack doesn't @@ -707,21 +700,26 @@ private extension ChatDetailView { // PERF: iterate reversed messages directly, avoid Array(enumerated()) allocation. // Use message.id identity (stable) — integer indices shift on insert. + // PERF: VStack wrapper ensures each ForEach element produces + // exactly 1 view → SwiftUI uses FAST PATH (O(1) diffing). + // Without it: conditional unreadSeparator makes element count + // variable → SLOW PATH (O(n) full scan on every update). ForEach(messages.reversed()) { message in - let index = messageIndex(for: message.id) - let position = bubblePosition(for: index) - messageRow( - message, - maxBubbleWidth: maxBubbleWidth, - position: position - ) - .scaleEffect(x: 1, y: -1) // flip each row back to normal + VStack(spacing: 0) { + let index = messageIndex(for: message.id) + let position = bubblePosition(for: index) + messageRow( + message, + maxBubbleWidth: maxBubbleWidth, + position: position + ) + .scaleEffect(x: 1, y: -1) // flip each row back to normal - // Unread Messages separator (Telegram style). - // In inverted scroll, "above" visually = after in code. - if message.id == firstUnreadMessageId { - unreadSeparator - .scaleEffect(x: 1, y: -1) + // Unread Messages separator (Telegram style). + if message.id == firstUnreadMessageId { + unreadSeparator + .scaleEffect(x: 1, y: -1) + } } } } @@ -820,6 +818,7 @@ private extension ChatDetailView { @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 @@ -974,14 +973,14 @@ private extension ChatDetailView { .padding(.top, 3) // Forwarded image attachments — blurhash thumbnails (Android parity: ForwardedImagePreview). - ForEach(Array(imageAttachments.enumerated()), id: \.element.id) { _, att in + ForEach(imageAttachments, id: \.id) { att in forwardedImagePreview(attachment: att, width: imageContentWidth, outgoing: outgoing) .padding(.horizontal, 6) .padding(.top, 4) } // Forwarded file attachments. - ForEach(Array(fileAttachments.enumerated()), id: \.element.id) { _, att in + ForEach(fileAttachments, id: \.id) { att in forwardedFilePreview(attachment: att, outgoing: outgoing) .padding(.horizontal, 6) .padding(.top, 4) @@ -1221,27 +1220,30 @@ private extension ChatDetailView { } // Non-image attachments (file, avatar) — padded + // PERF: Group ensures 1 view per element → ForEach fast path. ForEach(otherAttachments, id: \.id) { attachment in - 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 - ) + 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 @@ -1384,7 +1386,11 @@ private extension ChatDetailView { /// 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] { return cached } + 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) @@ -1802,12 +1808,14 @@ private extension ChatDetailView { ) } ForEach(otherAttachments, id: \.id) { attachment in - 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() + 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 { @@ -1934,16 +1942,28 @@ private extension ChatDetailView { } } - /// Collects all image attachment IDs from the current chat and opens the gallery. + /// Collects all image attachments from the current chat and opens the gallery. + /// Android parity: `extractImagesFromMessages` in `ImageViewerScreen.kt` — includes + /// sender name, timestamp, and caption for each image. + /// Uses `ImageViewerPresenter` (UIKit overFullScreen) instead of SwiftUI fullScreenCover + /// to avoid the default bottom-sheet slide-up animation. func openImageViewer(attachmentId: String) { - var allImageIds: [String] = [] + var allImages: [ViewableImageInfo] = [] for message in messages { + let senderName = senderDisplayName(for: message.fromPublicKey) + let timestamp = Date(timeIntervalSince1970: Double(message.timestamp) / 1000) for attachment in message.attachments where attachment.type == .image { - allImageIds.append(attachment.id) + allImages.append(ViewableImageInfo( + attachmentId: attachment.id, + senderName: senderName, + timestamp: timestamp, + caption: message.text + )) } } - let index = allImageIds.firstIndex(of: attachmentId) ?? 0 - imageViewerState = ImageViewerState(attachmentIds: allImageIds, initialIndex: index) + let index = allImages.firstIndex(where: { $0.attachmentId == attachmentId }) ?? 0 + let state = ImageViewerState(images: allImages, initialIndex: index) + ImageViewerPresenter.shared.present(state: state) } func retryMessage(_ message: ChatMessage) { diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift index 63af19a..3a05d9f 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewModel.swift @@ -27,10 +27,13 @@ final class ChatDetailViewModel: ObservableObject { // Subscribe to messagesByDialog changes, filtered to our dialog only. // Broken into steps to help the Swift type-checker. let key = dialogKey + // Android parity: debounce(50ms) batches rapid message mutations + // (delivery status, read marks, sync bursts) into fewer UI updates. let messagesPublisher = repo.$messagesByDialog .map { (dict: [String: [ChatMessage]]) -> [ChatMessage] in dict[key] ?? [] } + .debounce(for: .milliseconds(50), scheduler: DispatchQueue.main) .removeDuplicates { (lhs: [ChatMessage], rhs: [ChatMessage]) -> Bool in guard lhs.count == rhs.count else { return false } for i in lhs.indices { @@ -40,10 +43,10 @@ final class ChatDetailViewModel: ObservableObject { } return true } - .receive(on: DispatchQueue.main) messagesPublisher .sink { [weak self] newMessages in + PerformanceLogger.shared.track("chatDetail.messagesEmit") self?.messages = newMessages } .store(in: &cancellables) diff --git a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift index e3dccc4..efeeaef 100644 --- a/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift +++ b/Rosetta/Features/Chats/ChatDetail/ImageGalleryViewer.swift @@ -4,17 +4,76 @@ import Photos // MARK: - Data Types +/// Per-image metadata for the gallery viewer. +/// Android parity: `ViewableImage` in `ImageViewerScreen.kt`. +struct ViewableImageInfo: Equatable, Identifiable { + let attachmentId: String + let senderName: String + let timestamp: Date + let caption: String + + var id: String { attachmentId } +} + /// State for the image gallery viewer. struct ImageViewerState: Equatable { - let attachmentIds: [String] + let images: [ViewableImageInfo] let initialIndex: Int } +// MARK: - ImageViewerPresenter + +/// UIHostingController subclass that hides the status bar. +/// Uses `AnyView` instead of generic `Content` to avoid a Swift compiler crash +/// in the SIL inliner (SR-XXXXX / rdar://XXXXX). +private final class StatusBarHiddenHostingController: UIHostingController { + override var prefersStatusBarHidden: Bool { true } + override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .fade } +} + +/// Presents the image gallery viewer using UIKit `overFullScreen` presentation +/// — no bottom-sheet slide-up. Appears instantly; the viewer itself fades in. +/// Telegram parity: the viewer appears as a fade overlay covering nav bar and tab bar. +@MainActor +final class ImageViewerPresenter { + + static let shared = ImageViewerPresenter() + private weak var presentedController: UIViewController? + + func present(state: ImageViewerState) { + guard presentedController == nil else { return } + + let viewer = ImageGalleryViewer(state: state, onDismiss: { [weak self] in + self?.dismiss() + }) + + let hostingController = StatusBarHiddenHostingController(rootView: AnyView(viewer)) + hostingController.modalPresentationStyle = .overFullScreen + hostingController.view.backgroundColor = .clear + + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = windowScene.keyWindow?.rootViewController + else { return } + + var presenter = root + while let presented = presenter.presentedViewController { + presenter = presented + } + presenter.present(hostingController, animated: false) + presentedController = hostingController + } + + func dismiss() { + presentedController?.dismiss(animated: false) + presentedController = nil + } +} + // MARK: - ImageGalleryViewer /// Telegram-style multi-photo gallery viewer with horizontal paging. -/// Android parity: `ImageViewerScreen.kt` — HorizontalPager, zoom-to-point, -/// velocity dismiss, page counter, share/save. +/// Android parity: `ImageViewerScreen.kt` — top bar with sender/date, +/// bottom caption bar, edge-tap navigation, velocity dismiss, share/save. struct ImageGalleryViewer: View { let state: ImageViewerState @@ -23,6 +82,16 @@ struct ImageGalleryViewer: View { @State private var currentPage: Int @State private var showControls = true @State private var currentZoomScale: CGFloat = 1.0 + @State private var backgroundOpacity: Double = 1.0 + @State private var isDismissing = false + /// Entry/exit animation progress (0 = hidden, 1 = fully visible). + @State private var presentationAlpha: Double = 0 + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "d MMMM, HH:mm" + return formatter + }() init(state: ImageViewerState, onDismiss: @escaping () -> Void) { self.state = state @@ -30,76 +99,135 @@ struct ImageGalleryViewer: View { self._currentPage = State(initialValue: state.initialIndex) } + private var currentInfo: ViewableImageInfo? { + state.images.indices.contains(currentPage) ? state.images[currentPage] : nil + } + var body: some View { ZStack { - Color.black.ignoresSafeArea() + // Background — fades during drag-to-dismiss and entry/exit + Color.black + .opacity(backgroundOpacity * presentationAlpha) + .ignoresSafeArea() // Pager TabView(selection: $currentPage) { - ForEach(Array(state.attachmentIds.enumerated()), id: \.element) { index, attachmentId in + ForEach(Array(state.images.enumerated()), id: \.element.attachmentId) { index, info in ZoomableImagePage( - attachmentId: attachmentId, - onDismiss: onDismiss, + attachmentId: info.attachmentId, + onDismiss: { smoothDismiss() }, + onDismissProgress: { progress in + backgroundOpacity = 1.0 - Double(progress) * 0.7 + }, + onDismissCancel: { + withAnimation(.easeOut(duration: 0.25)) { + backgroundOpacity = 1.0 + } + }, showControls: $showControls, - currentScale: $currentZoomScale + currentScale: $currentZoomScale, + onEdgeTap: { direction in + navigateEdgeTap(direction: direction) + } ) .tag(index) } } .tabViewStyle(.page(indexDisplayMode: .never)) - .disabled(currentZoomScale > 1.05) + .disabled(currentZoomScale > 1.05 || isDismissing) + .opacity(presentationAlpha) // Controls overlay - if showControls { - controlsOverlay - .transition(.opacity) - } + controlsOverlay + .opacity(presentationAlpha) } .statusBarHidden(true) - .animation(.easeInOut(duration: 0.2), value: showControls) - .onChange(of: currentPage) { _, newPage in - prefetchAdjacentImages(around: newPage) - } .onAppear { prefetchAdjacentImages(around: state.initialIndex) + // Android: 200ms entry animation (TelegramEasing) + withAnimation(.easeOut(duration: 0.2)) { + presentationAlpha = 1.0 + } + } + .onChange(of: currentPage) { _, newPage in + prefetchAdjacentImages(around: newPage) } } // MARK: - Controls Overlay + @ViewBuilder private var controlsOverlay: some View { - VStack { - // Top bar: close + counter — inside safe area to avoid notch/Dynamic Island overlap - HStack { - Button { onDismiss() } label: { - Image(systemName: "xmark") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.white) - .frame(width: 36, height: 36) - .background(Color.white.opacity(0.2)) - .clipShape(Circle()) - } - .padding(.leading, 16) - + if showControls && !isDismissing { + VStack(spacing: 0) { + topBar Spacer() - - if state.attachmentIds.count > 1 { - Text("\(currentPage + 1) / \(state.attachmentIds.count)") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white.opacity(0.8)) - } - - Spacer() - - // Invisible spacer to balance the close button - Color.clear.frame(width: 36, height: 36) - .padding(.trailing, 16) + bottomBar + } + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: showControls) + } + } + + // MARK: - Top Bar (Android: sender name + date, back arrow) + + private var topBar: some View { + HStack(spacing: 8) { + // Back button (Android: arrow back on left) + Button { smoothDismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + .frame(width: 44, height: 44) + } + + // Sender name + date + if let info = currentInfo { + VStack(alignment: .leading, spacing: 1) { + Text(info.senderName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.white) + .lineLimit(1) + + Text(Self.dateFormatter.string(from: info.timestamp)) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.7)) + } } - .padding(.top, 54) Spacer() - // Bottom bar: share + save + // Page counter (if multiple images) + if state.images.count > 1 { + Text("\(currentPage + 1) / \(state.images.count)") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.8)) + .padding(.trailing, 8) + } + } + .padding(.horizontal, 4) + .padding(.vertical, 8) + // Extend dark background up into the notch / Dynamic Island safe area + .background(Color.black.opacity(0.5).ignoresSafeArea(edges: .top)) + } + + // MARK: - Bottom Bar (Caption + Share/Save) + + private var bottomBar: some View { + VStack(spacing: 0) { + // Caption text (Android: AppleEmojiText, 15sp, 4 lines max) + if let caption = currentInfo?.caption, !caption.isEmpty { + Text(caption) + .font(.system(size: 15)) + .foregroundStyle(.white) + .lineLimit(4) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.black.opacity(0.5)) + } + + // Action buttons HStack(spacing: 32) { Button { shareCurrentImage() } label: { Image(systemName: "square.and.arrow.up") @@ -118,15 +246,41 @@ struct ImageGalleryViewer: View { } } .padding(.horizontal, 24) - .padding(.bottom, 34) + .padding(.bottom, 8) + // Extend dark background down into the home indicator safe area + .background(Color.black.opacity(0.5).ignoresSafeArea(edges: .bottom)) + } + } + + // MARK: - Edge Tap Navigation + + private func navigateEdgeTap(direction: Int) { + let target = currentPage + direction + guard target >= 0, target < state.images.count else { return } + // Android: instant page switch with short fade (120ms) + currentPage = target + } + + // MARK: - Smooth Dismiss (Android: 200ms fade-out) + + private func smoothDismiss() { + guard !isDismissing else { return } + isDismissing = true + + withAnimation(.easeOut(duration: 0.2)) { + presentationAlpha = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + onDismiss() } } // MARK: - Actions private func shareCurrentImage() { - guard currentPage < state.attachmentIds.count, - let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage]) + guard let info = currentInfo, + let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) else { return } let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) @@ -146,8 +300,8 @@ struct ImageGalleryViewer: View { } private func saveCurrentImage() { - guard currentPage < state.attachmentIds.count, - let image = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[currentPage]) + guard let info = currentInfo, + let image = AttachmentCache.shared.loadImage(forAttachmentId: info.attachmentId) else { return } PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in @@ -159,11 +313,12 @@ struct ImageGalleryViewer: View { // MARK: - Prefetch private func prefetchAdjacentImages(around index: Int) { - for offset in [-1, 1] { + // Android: prefetches ±2 images from current page + for offset in [-2, -1, 1, 2] { let i = index + offset - guard i >= 0, i < state.attachmentIds.count else { continue } - // Touch cache to warm it (loads from disk if needed) - _ = AttachmentCache.shared.loadImage(forAttachmentId: state.attachmentIds[i]) + guard i >= 0, i < state.images.count else { continue } + _ = AttachmentCache.shared.loadImage(forAttachmentId: state.images[i].attachmentId) } } + } diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index 7fd2caa..05b9810 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -152,11 +152,15 @@ struct MessageImageView: View { /// Calculates display size respecting min/max constraints and aspect ratio (standalone mode). private func constrainedSize(for img: UIImage) -> CGSize { let constrainedWidth = min(maxImageWidth, maxWidth) - let aspectRatio = img.size.width / max(img.size.height, 1) + // Guard: zero-size images (corrupted or failed downsampling) use placeholder size. + guard img.size.width > 0, img.size.height > 0 else { + return CGSize(width: min(placeholderWidth, constrainedWidth), height: placeholderHeight) + } + let aspectRatio = img.size.width / img.size.height let displayWidth = min(constrainedWidth, max(minImageWidth, img.size.width)) - let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / aspectRatio)) + let displayHeight = min(maxImageHeight, max(minImageHeight, displayWidth / max(aspectRatio, 0.01))) let finalWidth = min(constrainedWidth, displayHeight * aspectRatio) - return CGSize(width: finalWidth, height: displayHeight) + return CGSize(width: max(finalWidth, 1), height: max(displayHeight, 1)) } // MARK: - Placeholder @@ -214,6 +218,7 @@ struct MessageImageView: View { // MARK: - Download private func loadFromCache() { + PerformanceLogger.shared.track("image.cacheLoad") if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { image = cached } diff --git a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift index dab99ec..c9d5666 100644 --- a/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift +++ b/Rosetta/Features/Chats/ChatDetail/ZoomableImagePage.swift @@ -3,71 +3,40 @@ import UIKit // MARK: - ZoomableImagePage -/// Single page in the image gallery viewer with centroid-based zoom. -/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — pinch zoom to centroid, -/// double-tap to tap point, velocity-based dismiss, touch slop. +/// Single page in the image gallery viewer with UIKit-based gesture handling. +/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` — centroid-based pinch zoom, +/// double-tap to tap point, velocity-based dismiss, axis locking, edge tap navigation. struct ZoomableImagePage: View { let attachmentId: String let onDismiss: () -> Void + let onDismissProgress: (CGFloat) -> Void + let onDismissCancel: () -> Void @Binding var showControls: Bool @Binding var currentScale: CGFloat + let onEdgeTap: ((Int) -> Void)? @State private var image: UIImage? - @State private var scale: CGFloat = 1.0 - @State private var offset: CGSize = .zero - @State private var dismissOffset: CGFloat = 0 - @State private var dismissStartTime: Date? - - private let minScale: CGFloat = 1.0 - private let maxScale: CGFloat = 5.0 - private let doubleTapScale: CGFloat = 2.5 - private let dismissDistanceThreshold: CGFloat = 100 - private let dismissVelocityThreshold: CGFloat = 800 - private let touchSlop: CGFloat = 20 var body: some View { - GeometryReader { geometry in - ZStack { - // Background fade during dismiss - Color.black - .opacity(backgroundOpacity) - .ignoresSafeArea() - - if let image { - imageContent(image, in: geometry) - } else { - placeholder - } + Group { + if let image { + ZoomableImageUIViewRepresentable( + image: image, + onDismiss: onDismiss, + onDismissProgress: onDismissProgress, + onDismissCancel: onDismissCancel, + onToggleControls: { showControls.toggle() }, + onScaleChanged: { scale in currentScale = scale }, + onEdgeTap: onEdgeTap + ) + } else { + placeholder } } .task { image = AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) } - .onChange(of: scale) { _, newValue in - currentScale = newValue - } - } - - // MARK: - Image Content - - @ViewBuilder - private func imageContent(_ image: UIImage, in geometry: GeometryProxy) -> some View { - let size = geometry.size - - Image(uiImage: image) - .resizable() - .scaledToFit() - .scaleEffect(scale) - .offset(x: offset.width, y: offset.height + dismissOffset) - .gesture(doubleTapGesture(in: size)) - .gesture(pinchGesture(in: size)) - .gesture(dragGesture(in: size)) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - showControls.toggle() - } - } } // MARK: - Placeholder @@ -81,118 +50,393 @@ struct ZoomableImagePage: View { .foregroundStyle(.white.opacity(0.5)) } } +} - // MARK: - Background Opacity +// MARK: - UIViewRepresentable - private var backgroundOpacity: Double { - let progress = min(abs(dismissOffset) / 300, 1.0) - return 1.0 - progress * 0.6 +/// Wraps `ImageGestureContainerView` for SwiftUI integration. +private struct ZoomableImageUIViewRepresentable: UIViewRepresentable { + + let image: UIImage + let onDismiss: () -> Void + let onDismissProgress: (CGFloat) -> Void + let onDismissCancel: () -> Void + let onToggleControls: () -> Void + let onScaleChanged: (CGFloat) -> Void + let onEdgeTap: ((Int) -> Void)? + + func makeUIView(context: Context) -> ImageGestureContainerView { + let view = ImageGestureContainerView(image: image) + view.onDismiss = onDismiss + view.onDismissProgress = onDismissProgress + view.onDismissCancel = onDismissCancel + view.onToggleControls = onToggleControls + view.onScaleChanged = onScaleChanged + view.onEdgeTap = onEdgeTap + return view } - // MARK: - Double Tap (zoom to tap point) + func updateUIView(_ view: ImageGestureContainerView, context: Context) { + view.onDismiss = onDismiss + view.onDismissProgress = onDismissProgress + view.onDismissCancel = onDismissCancel + view.onToggleControls = onToggleControls + view.onScaleChanged = onScaleChanged + view.onEdgeTap = onEdgeTap + } +} - private func doubleTapGesture(in size: CGSize) -> some Gesture { - SpatialTapGesture(count: 2) - .onEnded { value in - withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { - if scale > 1.05 { - // Zoom out to 1x - scale = 1.0 - offset = .zero - } else { - // Zoom in to tap point - let tapPoint = value.location - let viewCenter = CGPoint(x: size.width / 2, y: size.height / 2) - scale = doubleTapScale - // Shift image so tap point ends up at screen center - offset = CGSize( - width: (viewCenter.x - tapPoint.x) * (doubleTapScale - 1), - height: (viewCenter.y - tapPoint.y) * (doubleTapScale - 1) - ) +// MARK: - ImageGestureContainerView + +/// UIKit view that handles all image gestures with full control: +/// - Centroid-based pinch zoom (1x–5x) +/// - Double-tap to zoom to tap point (2.5x) or reset +/// - Pan when zoomed (with offset clamping) +/// - Vertical drag to dismiss with velocity tracking +/// - Single tap: edge zones navigate, center toggles controls +/// - Axis locking: decides vertical dismiss vs pan early +/// +/// Android parity: `ZoomableImage` in `ImageViewerScreen.kt` +final class ImageGestureContainerView: UIView, UIGestureRecognizerDelegate { + + // MARK: - Configuration + + private let minScale: CGFloat = 1.0 + private let maxScale: CGFloat = 5.0 + private let doubleTapScale: CGFloat = 2.5 + private let dismissDistanceThreshold: CGFloat = 100 + private let dismissVelocityThreshold: CGFloat = 500 + private let touchSlop: CGFloat = 20 + /// Android: left/right 20% zones are edge-tap navigation areas. + private let edgeTapFraction: CGFloat = 0.20 + /// Android: spring(dampingRatio = 0.9, stiffness = 400) ≈ UIKit(damping: 0.9, velocity: 0) + private let springDamping: CGFloat = 0.9 + private let springDuration: CGFloat = 0.35 + + // MARK: - Subviews + + private let imageView = UIImageView() + + // MARK: - Transform State + + private var currentScale: CGFloat = 1.0 + private var currentOffset: CGPoint = .zero + private var dismissOffset: CGFloat = 0 + + // Pinch gesture tracking + private var pinchStartScale: CGFloat = 1.0 + private var pinchStartOffset: CGPoint = .zero + private var lastPinchCentroid: CGPoint = .zero + + // Pan gesture tracking + private var panStartOffset: CGPoint = .zero + private var isDismissGesture = false + private var gestureAxisLocked = false + + // MARK: - Callbacks + + var onDismiss: (() -> Void)? + var onDismissProgress: ((CGFloat) -> Void)? + var onDismissCancel: (() -> Void)? + var onToggleControls: (() -> Void)? + var onScaleChanged: ((CGFloat) -> Void)? + /// -1 = left edge, 1 = right edge + var onEdgeTap: ((Int) -> Void)? + + // MARK: - Init + + init(image: UIImage) { + super.init(frame: .zero) + imageView.image = image + imageView.contentMode = .scaleAspectFit + imageView.isUserInteractionEnabled = false + addSubview(imageView) + clipsToBounds = true + setupGestures() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError() } + + // MARK: - Layout + + /// Track the last laid-out size so we only reset frame when it actually changes. + /// Without this, SwiftUI state changes (e.g. `onDismissProgress`) trigger + /// `layoutSubviews` → `imageView.frame = bounds` which RESETS the UIKit transform, + /// causing the image to snap back during dismiss drag. + private var lastLayoutSize: CGSize = .zero + + override func layoutSubviews() { + super.layoutSubviews() + guard lastLayoutSize != bounds.size else { return } + lastLayoutSize = bounds.size + // Temporarily reset transform, update frame, then re-apply. + let savedTransform = imageView.transform + imageView.transform = .identity + imageView.frame = bounds + imageView.transform = savedTransform + } + + // MARK: - Gesture Setup + + private func setupGestures() { + let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch)) + pinch.delegate = self + addGestureRecognizer(pinch) + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + pan.delegate = self + pan.maximumNumberOfTouches = 1 + addGestureRecognizer(pan) + + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap)) + doubleTap.numberOfTapsRequired = 2 + addGestureRecognizer(doubleTap) + + let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleSingleTap)) + singleTap.numberOfTapsRequired = 1 + singleTap.require(toFail: doubleTap) + addGestureRecognizer(singleTap) + } + + // MARK: - Apply Transform + + private func applyTransform(animated: Bool = false) { + // Guard against NaN/Infinity — prevents CoreGraphics crash and UI freeze. + if currentScale.isNaN || currentScale.isInfinite { currentScale = 1.0 } + if currentOffset.x.isNaN || currentOffset.x.isInfinite { currentOffset.x = 0 } + if currentOffset.y.isNaN || currentOffset.y.isInfinite { currentOffset.y = 0 } + if dismissOffset.isNaN || dismissOffset.isInfinite { dismissOffset = 0 } + + let transform = CGAffineTransform.identity + .translatedBy(x: currentOffset.x, y: currentOffset.y + dismissOffset) + .scaledBy(x: currentScale, y: currentScale) + + if animated { + UIView.animate( + withDuration: springDuration, + delay: 0, + usingSpringWithDamping: springDamping, + initialSpringVelocity: 0, + options: [.curveEaseOut] + ) { + self.imageView.transform = transform + } + } else { + imageView.transform = transform + } + } + + // MARK: - Pinch Gesture (Centroid Zoom) + + @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { + switch gesture.state { + case .began: + pinchStartScale = currentScale + pinchStartOffset = currentOffset + if gesture.numberOfTouches >= 2 { + lastPinchCentroid = gesture.location(in: self) + } + + case .changed: + let newScale = min(max(pinchStartScale * gesture.scale, minScale * 0.5), maxScale) + + // Centroid-based zoom: keep the point under fingers stationary + if gesture.numberOfTouches >= 2 { + let centroid = gesture.location(in: self) + let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY) + let gesturePoint = CGPoint(x: centroid.x - viewCenter.x, y: centroid.y - viewCenter.y) + + let safeCurrentScale = max(currentScale, 0.01) + let scaleRatio = newScale / safeCurrentScale + guard scaleRatio.isFinite else { break } + currentOffset = CGPoint( + x: gesturePoint.x - (gesturePoint.x - currentOffset.x) * scaleRatio, + y: gesturePoint.y - (gesturePoint.y - currentOffset.y) * scaleRatio + ) + lastPinchCentroid = centroid + } + + currentScale = newScale + onScaleChanged?(currentScale) + applyTransform() + + case .ended, .cancelled: + if currentScale < minScale + 0.05 { + // Snap back to 1x + currentScale = minScale + currentOffset = .zero + onScaleChanged?(minScale) + applyTransform(animated: true) + } else { + clampOffset(animated: true) + } + + default: break + } + } + + // MARK: - Pan Gesture (Pan when zoomed, Dismiss when not) + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: self) + let velocity = gesture.velocity(in: self) + + switch gesture.state { + case .began: + panStartOffset = currentOffset + gestureAxisLocked = false + isDismissGesture = false + + case .changed: + if currentScale > 1.05 { + // Zoomed: pan the image + currentOffset = CGPoint( + x: panStartOffset.x + translation.x, + y: panStartOffset.y + translation.y + ) + applyTransform() + } else { + // Not zoomed: detect axis + if !gestureAxisLocked { + let dx = abs(translation.x) + let dy = abs(translation.y) + // Android: abs(panChange.y) > abs(panChange.x) * 1.5 + if dx > touchSlop || dy > touchSlop { + gestureAxisLocked = true + isDismissGesture = dy > dx * 1.2 } } - } - } - // MARK: - Pinch Gesture (zoom to centroid) - - private func pinchGesture(in size: CGSize) -> some Gesture { - MagnificationGesture() - .onChanged { value in - let newScale = min(max(value * (scale > 0.01 ? 1.0 : scale), minScale * 0.5), maxScale) - // MagnificationGesture doesn't provide centroid, so zoom to center. - // For true centroid zoom, we'd need UIKit gesture recognizers. - // This is acceptable — most users don't notice centroid vs center on mobile. - scale = newScale - } - .onEnded { _ in - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { - if scale < minScale { - scale = minScale - offset = .zero - } - clampOffset(in: size) + if isDismissGesture { + dismissOffset = translation.y + let progress = min(abs(dismissOffset) / 300, 1.0) + onDismissProgress?(progress) + applyTransform() } } - } - // MARK: - Drag Gesture (pan when zoomed, dismiss when not) - - private func dragGesture(in size: CGSize) -> some Gesture { - DragGesture(minimumDistance: touchSlop) - .onChanged { value in - if scale > 1.05 { - // Zoomed: pan image - offset = CGSize( - width: value.translation.width, - height: value.translation.height - ) + case .ended, .cancelled: + if currentScale > 1.05 { + clampOffset(animated: true) + } else if isDismissGesture { + let velocityY = abs(velocity.y) + if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold { + // Dismiss with fade-out (Android: smoothDismiss 200ms fade) + onDismiss?() } else { - // Not zoomed: check if vertical dominant (dismiss) or horizontal (page swipe) - let dx = abs(value.translation.width) - let dy = abs(value.translation.height) - if dy > dx * 1.2 { - if dismissStartTime == nil { - dismissStartTime = Date() - } - dismissOffset = value.translation.height - } + // Snap back + dismissOffset = 0 + onDismissCancel?() + applyTransform(animated: true) } } - .onEnded { value in - if scale > 1.05 { - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { - clampOffset(in: size) - } - } else { - // Calculate velocity for dismiss - let elapsed = dismissStartTime.map { Date().timeIntervalSince($0) } ?? 0.3 - let velocityY = abs(dismissOffset) / max(elapsed, 0.01) + isDismissGesture = false + gestureAxisLocked = false - if abs(dismissOffset) > dismissDistanceThreshold || velocityY > dismissVelocityThreshold { - onDismiss() - } else { - withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) { - dismissOffset = 0 - } - } - dismissStartTime = nil - } - } + default: break + } + } + + // MARK: - Double Tap (Zoom to tap point) + + @objc private func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + let tapPoint = gesture.location(in: self) + let viewCenter = CGPoint(x: bounds.midX, y: bounds.midY) + + if currentScale > 1.1 { + // Zoom out to 1x + currentScale = minScale + currentOffset = .zero + onScaleChanged?(minScale) + applyTransform(animated: true) + } else { + // Zoom in to tap point at 2.5x (Android: tapX - tapX * targetScale) + let tapX = tapPoint.x - viewCenter.x + let tapY = tapPoint.y - viewCenter.y + + currentScale = doubleTapScale + currentOffset = CGPoint( + x: tapX - tapX * doubleTapScale, + y: tapY - tapY * doubleTapScale + ) + clampOffsetImmediate() + onScaleChanged?(doubleTapScale) + applyTransform(animated: true) + } + } + + // MARK: - Single Tap (Edge navigation or toggle controls) + + @objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) { + guard currentScale <= 1.05 else { + // When zoomed, single tap always toggles controls + onToggleControls?() + return + } + + let tapX = gesture.location(in: self).x + let width = bounds.width + let edgeZone = width * edgeTapFraction + + if tapX < edgeZone { + onEdgeTap?(-1) // Previous + } else if tapX > width - edgeZone { + onEdgeTap?(1) // Next + } else { + onToggleControls?() + } } // MARK: - Offset Clamping - private func clampOffset(in size: CGSize) { - guard scale > 1.0 else { - offset = .zero + private func clampOffset(animated: Bool) { + guard currentScale > 1.0 else { + currentOffset = .zero + applyTransform(animated: animated) return } - let maxOffsetX = size.width * (scale - 1) / 2 - let maxOffsetY = size.height * (scale - 1) / 2 - offset = CGSize( - width: min(max(offset.width, -maxOffsetX), maxOffsetX), - height: min(max(offset.height, -maxOffsetY), maxOffsetY) + let clamped = clampedOffset() + if currentOffset != clamped { + currentOffset = clamped + applyTransform(animated: animated) + } + } + + private func clampOffsetImmediate() { + currentOffset = clampedOffset() + } + + private func clampedOffset() -> CGPoint { + let maxX = max(bounds.width * (currentScale - 1) / 2, 0) + let maxY = max(bounds.height * (currentScale - 1) / 2, 0) + return CGPoint( + x: min(max(currentOffset.x, -maxX), maxX), + y: min(max(currentOffset.y, -maxY), maxY) ) } + + // MARK: - UIGestureRecognizerDelegate + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer + ) -> Bool { + // Allow pinch + pan simultaneously (zoom + drag) + let isPinchPan = (gestureRecognizer is UIPinchGestureRecognizer && other is UIPanGestureRecognizer) || + (gestureRecognizer is UIPanGestureRecognizer && other is UIPinchGestureRecognizer) + return isPinchPan + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } + let velocity = pan.velocity(in: self) + + if currentScale <= 1.05 { + // Not zoomed: only begin for vertical-dominant drags. + // Let horizontal swipes pass through to TabView for paging. + return abs(velocity.y) > abs(velocity.x) * 1.2 + } + return true + } } diff --git a/Rosetta/Features/Chats/ChatList/ChatListView.swift b/Rosetta/Features/Chats/ChatList/ChatListView.swift index 9e9fd38..fe61647 100644 --- a/Rosetta/Features/Chats/ChatList/ChatListView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatListView.swift @@ -564,6 +564,7 @@ private struct ChatListDialogContent: View { @State private var typingDialogs: Set = [] var body: some View { + let _ = PerformanceLogger.shared.track("chatList.bodyEval") // Use pre-partitioned arrays from ViewModel (single-pass O(n) instead of 3× filter). let pinned = viewModel.allModePinned let unpinned = viewModel.allModeUnpinned diff --git a/Rosetta/Features/Chats/ChatList/ChatRowView.swift b/Rosetta/Features/Chats/ChatList/ChatRowView.swift index 61eb33d..cf1b670 100644 --- a/Rosetta/Features/Chats/ChatList/ChatRowView.swift +++ b/Rosetta/Features/Chats/ChatList/ChatRowView.swift @@ -34,6 +34,7 @@ struct ChatRowView: View { } var body: some View { + let _ = PerformanceLogger.shared.track("chatRow.bodyEval") HStack(spacing: 0) { avatarSection .padding(.trailing, 10) diff --git a/Rosetta/Features/MainTabView.swift b/Rosetta/Features/MainTabView.swift index 3ae8259..ed6de05 100644 --- a/Rosetta/Features/MainTabView.swift +++ b/Rosetta/Features/MainTabView.swift @@ -31,6 +31,7 @@ struct MainTabView: View { } var body: some View { + let _ = PerformanceLogger.shared.track("mainTab.bodyEval") ZStack { Group { if #available(iOS 26.0, *) { @@ -57,6 +58,11 @@ struct MainTabView: View { // Full-screen device verification overlay (observation-isolated). // Covers nav bar, search bar, and tab bar — desktop parity. DeviceConfirmOverlay() + + // PERF: observation-isolated unread counter. + // Reads DialogRepository in its own scope — MainTabView.body + // never observes the dialogs dictionary directly. + UnreadCountObserver(count: $cachedUnreadCount) } } @@ -79,7 +85,7 @@ struct MainTabView: View { Label(RosettaTab.chats.label, systemImage: RosettaTab.chats.icon) } .tag(RosettaTab.chats) - .badge(chatUnreadCount) + .badge(cachedUnreadCount) SettingsView(onLogout: onLogout, onAddAccount: handleAddAccount, isEditingProfile: $isSettingsEditPresented, isDetailPresented: $isSettingsDetailPresented) .tabItem { @@ -195,20 +201,39 @@ struct MainTabView: View { return [TabBadge(tab: .chats, text: chatUnreadBadge)] } - /// Int badge for iOS 26+ TabView — `.badge(0)` shows nothing, - /// and being non-conditional preserves ChatListView's structural identity. - private var chatUnreadCount: Int { - DialogRepository.shared.sortedDialogs - .filter { !$0.isMuted } - .reduce(0) { $0 + $1.unreadCount } - } + /// PERF: cached unread count — updated by observation-isolated child view. + /// Reading DialogRepository.shared.sortedDialogs directly in MainTabView.body + /// creates observation on the entire dialogs dictionary, causing full body + /// re-evaluation on every dialog mutation (online, typing, delivery). + @State private var cachedUnreadCount: Int = 0 private var chatUnreadBadge: String? { - let unread = chatUnreadCount - if unread <= 0 { + if cachedUnreadCount <= 0 { return nil } - return unread > 999 ? "\(unread / 1000)K" : "\(unread)" + return cachedUnreadCount > 999 ? "\(cachedUnreadCount / 1000)K" : "\(cachedUnreadCount)" + } +} + +// MARK: - Observation-Isolated Unread Counter + +/// Invisible child view that reads DialogRepository in its OWN observation scope. +/// MainTabView.body never observes DialogRepository directly — only this tiny view +/// re-evaluates when dialogs change, updating the parent via @Binding. +private struct UnreadCountObserver: View { + @Binding var count: Int + + private var unreadTotal: Int { + DialogRepository.shared.sortedDialogs + .reduce(0) { $0 + ($1.isMuted ? 0 : $1.unreadCount) } + } + + var body: some View { + let total = unreadTotal + Color.clear + .frame(width: 0, height: 0) + .allowsHitTesting(false) + .task(id: total) { count = total } } } diff --git a/Rosetta/Features/Settings/SettingsView.swift b/Rosetta/Features/Settings/SettingsView.swift index 28611d5..d1c9c7d 100644 --- a/Rosetta/Features/Settings/SettingsView.swift +++ b/Rosetta/Features/Settings/SettingsView.swift @@ -358,6 +358,10 @@ struct SettingsView: View { themeCard safetyCard + #if DEBUG + debugCard + #endif + rosettaPowerFooter } .padding(.horizontal, 16) @@ -773,6 +777,29 @@ struct SettingsView: View { } } + // MARK: - Debug (stress test) + + #if DEBUG + private var debugCard: some View { + SettingsCard { + Button { + StressTestGenerator.generateMessages(count: 100, dialogKey: "stress_test_\(Int.random(in: 1000...9999))") + } label: { + HStack { + Spacer() + Text("🧪 Generate 100 Messages + Photos") + .font(.system(size: 17)) + .foregroundStyle(.orange) + Spacer() + } + .frame(height: 52) + .contentShape(Rectangle()) + } + .settingsHighlight() + } + } + #endif + // MARK: - Helpers /// Figma row: 52pt height, 30×30 rounded icon (r7), 23pt symbol, Medium 17pt title.