diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 541281a..3424f46 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -718,7 +718,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -734,7 +734,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.6; + MARKETING_VERSION = 1.3.7; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -758,7 +758,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 37; + CURRENT_PROJECT_VERSION = 38; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -774,7 +774,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.6; + MARKETING_VERSION = 1.3.7; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Crypto/CryptoManager.swift b/Rosetta/Core/Crypto/CryptoManager.swift index dd6fc12..a195f9a 100644 --- a/Rosetta/Core/Crypto/CryptoManager.swift +++ b/Rosetta/Core/Crypto/CryptoManager.swift @@ -50,9 +50,12 @@ final class CryptoManager: @unchecked Sendable { /// Caches decrypted results to avoid repeated AES + PBKDF2 for same input. /// Android: `CryptoManager.decryptionCache` (ConcurrentHashMap, max 2000). + /// PERF: Also tracks total memory size — evicts when >50MB to prevent OOM. nonisolated private static let decryptionCacheMaxSize = 2000 + nonisolated private static let decryptionCacheMaxBytes = 50 * 1024 * 1024 // 50MB nonisolated private let decryptionCacheLock = NSLock() nonisolated(unsafe) private var decryptionCache: [String: Data] = [:] + nonisolated(unsafe) private var decryptionCacheTotalBytes: Int = 0 private init() {} @@ -63,6 +66,7 @@ final class CryptoManager: @unchecked Sendable { pbkdf2CacheLock.unlock() decryptionCacheLock.lock() decryptionCache.removeAll() + decryptionCacheTotalBytes = 0 decryptionCacheLock.unlock() } @@ -190,12 +194,19 @@ final class CryptoManager: @unchecked Sendable { /// Store result in decryption cache with eviction (Android: 10% eviction at 2000 entries). private nonisolated func cacheDecryptionResult(_ key: String, _ value: Data) { decryptionCacheLock.lock() - if decryptionCache.count >= Self.decryptionCacheMaxSize { - // Evict ~10% oldest entries - let keysToRemove = Array(decryptionCache.keys.prefix(Self.decryptionCacheMaxSize / 10)) - for k in keysToRemove { decryptionCache.removeValue(forKey: k) } + // PERF: Evict when count exceeds max OR total memory exceeds 50MB. + if decryptionCache.count >= Self.decryptionCacheMaxSize + || decryptionCacheTotalBytes > Self.decryptionCacheMaxBytes { + let evictCount = max(Self.decryptionCacheMaxSize / 10, 50) + let keysToRemove = Array(decryptionCache.keys.prefix(evictCount)) + for k in keysToRemove { + if let removed = decryptionCache.removeValue(forKey: k) { + decryptionCacheTotalBytes -= removed.count + } + } } decryptionCache[key] = value + decryptionCacheTotalBytes += value.count decryptionCacheLock.unlock() } diff --git a/Rosetta/Core/Data/Database/DatabaseManager.swift b/Rosetta/Core/Data/Database/DatabaseManager.swift index 99dc846..c05c335 100644 --- a/Rosetta/Core/Data/Database/DatabaseManager.swift +++ b/Rosetta/Core/Data/Database/DatabaseManager.swift @@ -54,6 +54,10 @@ final class DatabaseManager { config.prepareDatabase { db in // WAL mode for concurrent reads (Android parity: JournalMode.WRITE_AHEAD_LOGGING) try db.execute(sql: "PRAGMA journal_mode = WAL") + // PERF: NORMAL sync = async fsync; reduces write latency ~2-3× vs FULL. + try db.execute(sql: "PRAGMA synchronous = NORMAL") + // PERF: Checkpoint after 1000 pages (~4MB) instead of default 1000 frames. + try db.execute(sql: "PRAGMA wal_autocheckpoint = 1000") } let pool = try DatabasePool(path: dbURL.path, configuration: config) @@ -794,6 +798,14 @@ final class DatabaseManager { } } + // PERF: Composite index for sorted dialog queries (pinned first, then by timestamp). + migrator.registerMigration("v9_perf_dialog_sort_index") { db in + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_dialogs_account_pinned_ts + ON dialogs(account, is_pinned, last_message_timestamp) + """) + } + try migrator.migrate(pool) dbPool = pool diff --git a/Rosetta/Core/Data/Repositories/AvatarRepository.swift b/Rosetta/Core/Data/Repositories/AvatarRepository.swift index 30fa726..89f41d4 100644 --- a/Rosetta/Core/Data/Repositories/AvatarRepository.swift +++ b/Rosetta/Core/Data/Repositories/AvatarRepository.swift @@ -78,7 +78,8 @@ final class AvatarRepository { } let key = normalizedKey(publicKey) if let cached = cache.object(forKey: key as NSString) { - syncAvatarToNotificationStoreIfNeeded(cached, normalizedKey: key) + // PERF: Don't sync to notification store on cache hit — sync only + // happens on disk load (below). Cache hits fire 50+/sec during scroll. return cached } let url = avatarURL(for: key) @@ -192,10 +193,16 @@ final class AvatarRepository { ) } + /// PERF: Moved to background queue — synchronous JPEG encode + disk write + /// was blocking main thread during cell configuration. + /// Only called on disk load (not cache hit), so runs once per avatar per session. private func syncAvatarToNotificationStoreIfNeeded(_ image: UIImage, normalizedKey: String) { guard let notificationURL = notificationAvatarURL(for: normalizedKey) else { return } - ensureDirectoryExists(notificationAvatarsDirectory) - guard let jpegData = image.jpegData(compressionQuality: 0.72) else { return } - try? jpegData.write(to: notificationURL, options: [.atomic]) + let dir = notificationAvatarsDirectory + DispatchQueue.global(qos: .utility).async { + self.ensureDirectoryExists(dir) + guard let jpegData = image.jpegData(compressionQuality: 0.72) else { return } + try? jpegData.write(to: notificationURL, options: [.atomic]) + } } } diff --git a/Rosetta/Core/Data/Repositories/DialogRepository.swift b/Rosetta/Core/Data/Repositories/DialogRepository.swift index 46c7b1f..267075a 100644 --- a/Rosetta/Core/Data/Repositories/DialogRepository.swift +++ b/Rosetta/Core/Data/Repositories/DialogRepository.swift @@ -13,14 +13,38 @@ final class DialogRepository { private(set) var dialogs: [String: Dialog] = [:] { didSet { + // PERF: Only invalidate the data snapshot (not sort order). + // Sort order (_sortedKeysCache) is invalidated explicitly only when + // isPinned, lastMessageTimestamp, or dialog count changes. _sortedDialogsCache = nil dialogsVersion &+= 1 } } + /// Call when sort order may have changed (pin/unpin, new message, add/remove dialog). + private func invalidateSortOrder() { + _sortedKeysCache = nil + _sortedDialogsCache = nil + } + @ObservationIgnored private(set) var dialogsVersion: Int = 0 private var currentAccount: String = "" + // MARK: - Dirty Tracking (PERF) + + /// Keys that need reconciliation (populated during sync). + @ObservationIgnored private var dirtyReconcileKeys: Set = [] + + /// Track a dialog key that needs reconciliation after sync. + func markDirtyForReconcile(_ opponentKey: String) { + dirtyReconcileKeys.insert(opponentKey) + } + + // MARK: - UserDefaults Debounce (PERF) + + @ObservationIgnored private var debouncedMutedWork: DispatchWorkItem? + @ObservationIgnored private var debouncedContactsWork: DispatchWorkItem? + // MARK: - Sort Caches @ObservationIgnored private var _sortedKeysCache: [String]? @@ -75,15 +99,15 @@ final class DialogRepository { print("[DB] DialogRepository bootstrap error: \(error)") dialogs = [:] } - _sortedKeysCache = nil + invalidateSortOrder() updateAppBadge() - syncMutedKeysToDefaults() - syncContactNamesToDefaults() + syncMutedKeysToDefaults(immediate: true) + syncContactNamesToDefaults(immediate: true) } func reset(clearPersisted: Bool = false) { dialogs.removeAll() - _sortedKeysCache = nil + invalidateSortOrder() UNUserNotificationCenter.current().setBadgeCount(0) UserDefaults.standard.set(0, forKey: "app_badge_count") UserDefaults(suiteName: "group.com.rosetta.dev")?.set(0, forKey: "app_badge_count") @@ -106,8 +130,14 @@ final class DialogRepository { func upsertDialog(_ dialog: Dialog) { if currentAccount.isEmpty { currentAccount = dialog.account } + let existing = dialogs[dialog.opponentKey] dialogs[dialog.opponentKey] = dialog - _sortedKeysCache = nil + // PERF: Only invalidate sort order when ordering fields change. + if existing == nil + || existing?.isPinned != dialog.isPinned + || existing?.lastMessageTimestamp != dialog.lastMessageTimestamp { + invalidateSortOrder() + } persistDialog(dialog) } @@ -129,7 +159,7 @@ final class DialogRepository { // No messages — remove dialog if it exists if existing != nil { dialogs.removeValue(forKey: opponentKey) - _sortedKeysCache = nil + invalidateSortOrder() do { try db.writeSync { db in try db.execute( @@ -209,8 +239,12 @@ final class DialogRepository { // Group sender key for "Alice: hey" chat list preview dialog.lastMessageSenderKey = lastMsg.fromPublicKey + let oldTimestamp = existing?.lastMessageTimestamp dialogs[opponentKey] = dialog - _sortedKeysCache = nil + // PERF: Only invalidate sort when timestamp or pin status changed. + if existing == nil || oldTimestamp != dialog.lastMessageTimestamp || existing?.isPinned != dialog.isPinned { + invalidateSortOrder() + } persistDialog(dialog) scheduleAppBadgeUpdate() } @@ -251,7 +285,7 @@ final class DialogRepository { lastMessageRead: false ) dialogs[opponentKey] = dialog - _sortedKeysCache = nil + invalidateSortOrder() // New dialog added persistDialog(dialog) syncContactNamesToDefaults() } @@ -314,7 +348,7 @@ final class DialogRepository { func deleteDialog(opponentKey: String) { dialogs.removeValue(forKey: opponentKey) - _sortedKeysCache = nil + invalidateSortOrder() do { try db.writeSync { db in try db.execute( @@ -337,7 +371,7 @@ final class DialogRepository { let messages = MessageRepository.shared.messages(for: opponentKey) if messages.isEmpty { dialogs.removeValue(forKey: opponentKey) - _sortedKeysCache = nil + invalidateSortOrder() do { try db.writeSync { db in try db.execute( @@ -360,19 +394,24 @@ final class DialogRepository { var updatedDialogs = dialogs updatedDialogs[opponentKey] = dialog dialogs = updatedDialogs - _sortedKeysCache = nil + invalidateSortOrder() // Pin status changed persistDialog(dialog) } - /// Android parity: full recalculation for ALL dialogs. - /// Replaces separate reconcileUnreadCounts + reconcileDeliveryStatuses. + /// Android parity: recalculate dialogs that changed during sync. + /// PERF: Only reconciles dirty keys (tracked via markDirtyForReconcile). + /// Falls back to full scan if no dirty keys tracked (legacy callers). func reconcileAllDialogs() { - for opponentKey in Array(dialogs.keys) { + let keysToReconcile = dirtyReconcileKeys.isEmpty + ? Set(dialogs.keys) + : dirtyReconcileKeys + dirtyReconcileKeys.removeAll() + for opponentKey in keysToReconcile { updateDialogFromMessages(opponentKey: opponentKey) } } - /// Legacy shims for callers that still reference old methods. + /// Legacy shims — both route to reconcileAllDialogs (no-op if dirty set empty). func reconcileUnreadCounts() { reconcileAllDialogs() } func reconcileDeliveryStatuses() { /* handled by reconcileAllDialogs() */ } @@ -380,7 +419,7 @@ final class DialogRepository { guard var dialog = dialogs[opponentKey] else { return } dialog.isMuted.toggle() dialogs[opponentKey] = dialog - syncMutedKeysToDefaults() + syncMutedKeysToDefaults(immediate: true) persistDialog(dialog) scheduleAppBadgeUpdate() } @@ -445,7 +484,23 @@ final class DialogRepository { } catch { return nil } } - private func syncMutedKeysToDefaults() { + private func syncMutedKeysToDefaults(immediate: Bool = false) { + debouncedMutedWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor [weak self] in + self?.performSyncMutedKeysToDefaults() + } + } + debouncedMutedWork = work + if immediate { + work.perform() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: work) + } + } + + private func performSyncMutedKeysToDefaults() { var mutedKeys: [String] = [] for dialog in dialogs.values where dialog.isMuted { mutedKeys.append(dialog.opponentKey) @@ -465,7 +520,24 @@ final class DialogRepository { /// Sync contact display names to App Group for push notification name resolution. /// Both NSE and AppDelegate read this in background to show sender name in notification title. - private func syncContactNamesToDefaults() { + /// PERF: Debounced by 2s to avoid UserDefaults spam during sync. + private func syncContactNamesToDefaults(immediate: Bool = false) { + debouncedContactsWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + Task { @MainActor [weak self] in + self?.performSyncContactNamesToDefaults() + } + } + debouncedContactsWork = work + if immediate { + work.perform() + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: work) + } + } + + private func performSyncContactNamesToDefaults() { var names: [String: String] = [:] for dialog in dialogs.values { let name = dialog.opponentTitle.isEmpty ? dialog.opponentUsername : dialog.opponentTitle diff --git a/Rosetta/Core/Network/Protocol/ProtocolManager.swift b/Rosetta/Core/Network/Protocol/ProtocolManager.swift index dd60f4b..458a9fd 100644 --- a/Rosetta/Core/Network/Protocol/ProtocolManager.swift +++ b/Rosetta/Core/Network/Protocol/ProtocolManager.swift @@ -1005,10 +1005,10 @@ final class ProtocolManager: @unchecked Sendable { private func startHeartbeat(interval: Int) { heartbeatTask?.cancel() - // Send heartbeat every 5 seconds — aggressive keep-alive to prevent - // server/proxy idle timeouts. Server timeout is heartbeat*2 = 60s, - // so 5s gives 12× safety margin. - let intervalNs: UInt64 = 5_000_000_000 + // Use server-negotiated interval (clamped to 5–30s range). + // Previous 5s hardcode caused 3× more network/CPU than needed. + let clampedInterval = max(5, min(interval, 30)) + let intervalNs: UInt64 = UInt64(clampedInterval) * 1_000_000_000 // Send first heartbeat SYNCHRONOUSLY on current thread (URLSession delegate queue). // This bypasses the connectionState race: startHeartbeat() is called BEFORE diff --git a/Rosetta/Core/Services/InAppBannerManager.swift b/Rosetta/Core/Services/InAppBannerManager.swift index 1ec64ad..eab76f6 100644 --- a/Rosetta/Core/Services/InAppBannerManager.swift +++ b/Rosetta/Core/Services/InAppBannerManager.swift @@ -180,7 +180,14 @@ final class InAppBannerManager { } } -private final class InAppBannerWindow: UIWindow {} +private final class InAppBannerWindow: UIWindow { + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + // If only the window itself was hit (no subview claimed it), return nil + // so the touch passes through to the app window below. + return result === self ? nil : result + } +} private final class InAppBannerOverlayViewController: UIViewController { diff --git a/Rosetta/Core/Services/SessionManager.swift b/Rosetta/Core/Services/SessionManager.swift index a14a7dd..4a64bbc 100644 --- a/Rosetta/Core/Services/SessionManager.swift +++ b/Rosetta/Core/Services/SessionManager.swift @@ -1520,8 +1520,8 @@ final class SessionManager { self.requestSynchronize() // Safety net: reconcile dialog delivery indicators and unread counts // with actual message statuses, fixing any desync from stale retry timers. - DialogRepository.shared.reconcileDeliveryStatuses() - DialogRepository.shared.reconcileUnreadCounts() + // PERF: Single call — reconcileAllDialogs handles both. + DialogRepository.shared.reconcileAllDialogs() // NOTE: retryWaitingOutgoingMessages moved to NOT_NEEDED (Android parity: // finishSyncCycle() retries AFTER sync completes, not during). @@ -1561,8 +1561,7 @@ final class SessionManager { // updates 0 rows because the message isn't in DB yet. self.reapplyPendingSyncReads() self.reapplyPendingOpponentReads() - // Android parity: reconcile unread counts after each batch. - DialogRepository.shared.reconcileUnreadCounts() + // PERF: Skip per-batch reconcile — final reconcile at .notNeeded is sufficient. Self.logger.debug("SYNC BATCH_END cursor=\(serverCursor)") self.requestSynchronize(cursor: serverCursor) @@ -1572,8 +1571,8 @@ final class SessionManager { // Re-apply cross-device reads one final time. self.reapplyPendingSyncReads() self.reapplyPendingOpponentReads() - DialogRepository.shared.reconcileDeliveryStatuses() - DialogRepository.shared.reconcileUnreadCounts() + // PERF: Single call — reconcileAllDialogs handles both delivery + unread. + DialogRepository.shared.reconcileAllDialogs() self.retryWaitingOutgoingMessagesAfterReconnect() Self.logger.debug("SYNC NOT_NEEDED") // One-time recovery: re-sync from 0 if group keys are missing. @@ -1871,6 +1870,8 @@ final class SessionManager { // Android parity 1:1: dialogDao.updateDialogFromMessages(account, opponentKey) // Full recalculation of lastMessage, unread, iHaveSent, delivery from DB. DialogRepository.shared.updateDialogFromMessages(opponentKey: opponentKey) + // PERF: Track dirty key so end-of-sync reconcile only hits changed dialogs. + DialogRepository.shared.markDirtyForReconcile(opponentKey) if isGroupDialog, let metadata = GroupRepository.shared.groupMetadata(account: myKey, groupDialogKey: opponentKey) { DialogRepository.shared.ensureDialog( diff --git a/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift b/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift index afeafa9..acff86e 100644 --- a/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift +++ b/Rosetta/DesignSystem/Components/DeliveryCheckmark.swift @@ -2,54 +2,26 @@ import SwiftUI // MARK: - Single Checkmark (Delivered) -/// Single checkmark shape for delivery status. -/// Exact SVG path from design — renders at any size as a vector. -/// Original viewBox: 0 0 19 14, fill-rule: evenodd. +/// Single checkmark shape — Telegram-exact geometry from ChatListStatusNode.swift. +/// Stroke-based V-path converted to filled outline via `strokedPath`. +/// Canonical coordinates: bottom-left (0, 4.5), inflection (3.5, 8.0), tip (11.0, 0). struct SingleCheckmarkShape: Shape { func path(in rect: CGRect) -> Path { - let sx = rect.width / 19.0 - let sy = rect.height / 14.0 + let viewW: CGFloat = 11.0 + let viewH: CGFloat = 8.0 + let sx = rect.width / viewW + let sy = rect.height / viewH - var path = Path() + var line = Path() + line.move(to: CGPoint(x: 0, y: 4.5 * sy)) + line.addLine(to: CGPoint(x: 3.5 * sx, y: 8.0 * sy)) + line.addLine(to: CGPoint(x: 11.0 * sx, y: 0)) - path.move(to: CGPoint(x: 17.9693 * sx, y: 1.30564 * sy)) - path.addCurve( - to: CGPoint(x: 17.9283 * sx, y: 0.20733 * sy), - control1: CGPoint(x: 18.261 * sx, y: 0.991184 * sy), - control2: CGPoint(x: 18.2427 * sx, y: 0.498997 * sy) - ) - path.addCurve( - to: CGPoint(x: 16.83 * sx, y: 0.248345 * sy), - control1: CGPoint(x: 17.6138 * sx, y: -0.0843367 * sy), - control2: CGPoint(x: 17.1217 * sx, y: -0.0661078 * sy) - ) - path.addLine(to: CGPoint(x: 6.04743 * sx, y: 11.8603 * sy)) - path.addLine(to: CGPoint(x: 1.36254 * sx, y: 6.39613 * sy)) - path.addCurve( - to: CGPoint(x: 0.268786 * sx, y: 6.3141 * sy), - control1: CGPoint(x: 1.08454 * sx, y: 6.07256 * sy), - control2: CGPoint(x: 0.596911 * sx, y: 6.03611 * sy) - ) - path.addCurve( - to: CGPoint(x: 0.186756 * sx, y: 7.40785 * sy), - control1: CGPoint(x: -0.0547816 * sx, y: 6.5921 * sy), - control2: CGPoint(x: -0.0912399 * sx, y: 7.07973 * sy) - ) - path.addLine(to: CGPoint(x: 5.43676 * sx, y: 13.5329 * sy)) - path.addCurve( - to: CGPoint(x: 6.01097 * sx, y: 13.8017 * sy), - control1: CGPoint(x: 5.57803 * sx, y: 13.7015 * sy), - control2: CGPoint(x: 5.78767 * sx, y: 13.7972 * sy) - ) - path.addCurve( - to: CGPoint(x: 6.59431 * sx, y: 13.5556 * sy), - control1: CGPoint(x: 6.22972 * sx, y: 13.8063 * sy), - control2: CGPoint(x: 6.44392 * sx, y: 13.7151 * sy) - ) - path.addLine(to: CGPoint(x: 17.9693 * sx, y: 1.30564 * sy)) - path.closeSubpath() - - return path + return line.strokedPath(StrokeStyle( + lineWidth: min(sx, sy) * 1.35, + lineCap: .round, + lineJoin: .round + )) } } diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 80f6732..687006c 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -259,7 +259,7 @@ struct ChatDetailView: View { selectedMessageIds.insert(msgId) } } - cellActions.onMentionTap = { [self] username in + cellActions.onMentionTap = { [self] username, completion in // @all — no action (desktop parity) guard username.lowercased() != "all" else { return } // Tap own username → Saved Messages @@ -270,6 +270,7 @@ struct ChatDetailView: View { } else if let myKey = AccountManager.shared.currentAccount?.publicKey { mentionChatRoute = ChatRoute(publicKey: myKey, title: "Saved Messages", username: "", verified: 0) } + completion(true) return } // Find dialog by username → push directly (no flash to chat list) @@ -277,6 +278,9 @@ struct ChatDetailView: View { $0.opponentUsername.lowercased() == username.lowercased() }) { mentionChatRoute = ChatRoute(dialog: dialog) + completion(true) + } else { + completion(false) } } cellActions.onAvatarTap = { [self] senderKey in diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift index a478ab0..4437892 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailViewController.swift @@ -38,6 +38,7 @@ final class ChatDetailViewController: UIViewController { private var messageToDelete: ChatMessage? private var isMultiSelectMode = false private var selectedMessageIds: Set = [] + private var mentionSearchHandlerId: UUID? // MARK: - Selection Header (replaces nav chrome in-place) @@ -605,8 +606,8 @@ final class ChatDetailViewController: UIViewController { self.messageListController?.updateSelectedIds(self.selectedMessageIds) self.updateSelectionPillLabel() } - cellActions.onMentionTap = { [weak self] username in - self?.handleMentionTap(username: username) + cellActions.onMentionTap = { [weak self] username, completion in + self?.handleMentionTap(username: username, completion: completion) } cellActions.onAvatarTap = { [weak self] senderKey in self?.handleAvatarTap(senderKey: senderKey) @@ -662,6 +663,10 @@ final class ChatDetailViewController: UIViewController { } private func deactivateChat() { + if let hid = mentionSearchHandlerId { + ProtocolManager.shared.removeSearchResultHandler(hid) + mentionSearchHandlerId = nil + } firstUnreadMessageId = nil markDialogAsRead() isViewActive = false @@ -743,22 +748,72 @@ final class ChatDetailViewController: UIViewController { } } - private func handleMentionTap(username: String) { + private func handleMentionTap(username: String, completion: @escaping (Bool) -> Void) { guard username.lowercased() != "all" else { return } + let myUsername = AccountManager.shared.currentAccount?.username?.lowercased() ?? "" if !myUsername.isEmpty && username.lowercased() == myUsername { if let saved = DialogRepository.shared.sortedDialogs.first(where: { $0.isSavedMessages }) { let vc = ChatDetailViewController(route: ChatRoute(dialog: saved)) navigationController?.pushViewController(vc, animated: true) } + completion(true) return } + + // Local lookup if let dialog = DialogRepository.shared.sortedDialogs.first(where: { $0.opponentUsername.lowercased() == username.lowercased() }) { let vc = ChatDetailViewController(route: ChatRoute(dialog: dialog)) navigationController?.pushViewController(vc, animated: true) + completion(true) + return } + + // Server fallback — resolve unknown username + guard let hash = SessionManager.shared.privateKeyHash else { + completion(false) + return + } + + if let old = mentionSearchHandlerId { + ProtocolManager.shared.removeSearchResultHandler(old) + mentionSearchHandlerId = nil + } + + let searchId = UUID() + let handlerId = ProtocolManager.shared.addSearchResultHandler(channel: .ui(searchId)) { + [weak self] packet in + Task { @MainActor [weak self] in + guard let self else { return } + if let hid = self.mentionSearchHandlerId { + ProtocolManager.shared.removeSearchResultHandler(hid) + self.mentionSearchHandlerId = nil + } + if let user = packet.users.first(where: { + $0.username.lowercased() == username.lowercased() + }) { + let route = ChatRoute( + publicKey: user.publicKey, + title: user.title, + username: user.username, + verified: user.verified + ) + let vc = ChatDetailViewController(route: route) + self.navigationController?.pushViewController(vc, animated: true) + completion(true) + } else { + completion(false) + } + } + } + mentionSearchHandlerId = handlerId + + var packet = PacketSearch() + packet.privateKey = hash + packet.search = username + ProtocolManager.shared.sendSearchPacket(packet, channel: .ui(searchId)) } private func handleAvatarTap(senderKey: String) { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift index 043d86b..95dcfb9 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageCellActions.swift @@ -17,7 +17,7 @@ final class MessageCellActions { var onCall: (String) -> Void = { _ in } // peer public key var onGroupInviteTap: (String) -> Void = { _ in } // invite string var onGroupInviteOpen: (String) -> Void = { _ in } // group dialog key → navigate - var onMentionTap: (String) -> Void = { _ in } // username (without @) + var onMentionTap: (String, @escaping (Bool) -> Void) -> Void = { _, _ in } // username (without @), completion(found) var onAvatarTap: (String) -> Void = { _ in } // sender public key (group chats) // Multi-select diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift index b7126b8..7aa5032 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageCell.swift @@ -36,8 +36,11 @@ final class NativeMessageCell: UICollectionViewCell { private static let outgoingCheckColor = UIColor.white private static let outgoingClockColor = UIColor.white.withAlphaComponent(0.5) private static let mediaMetaColor = UIColor.white + private static let readCheckColor = UIColor(red: 0xA4/255, green: 0xE2/255, blue: 0xFF/255, alpha: 1) // #A4E2FF private static let fullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: outgoingCheckColor) private static let partialCheckImage = StatusIconRenderer.makeCheckImage(partial: true, color: outgoingCheckColor) + private static let fullCheckReadImage = StatusIconRenderer.makeCheckImage(partial: false, color: readCheckColor) + private static let partialCheckReadImage = StatusIconRenderer.makeCheckImage(partial: true, color: readCheckColor) private static let clockFrameImage = StatusIconRenderer.makeClockFrameImage(color: outgoingClockColor) private static let clockMinImage = StatusIconRenderer.makeClockMinImage(color: outgoingClockColor) private static let mediaFullCheckImage = StatusIconRenderer.makeCheckImage(partial: false, color: mediaMetaColor) @@ -780,10 +783,12 @@ final class NativeMessageCell: UICollectionViewCell { switch message.deliveryStatus { case .delivered: shouldShowSentCheck = true - checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage if message.isRead { - checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckImage + checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckReadImage + checkReadView.image = isMediaStatus ? Self.mediaPartialCheckImage : Self.partialCheckReadImage shouldShowReadCheck = true + } else { + checkSentView.image = isMediaStatus ? Self.mediaFullCheckImage : Self.fullCheckImage } case .waiting: shouldShowClock = true @@ -2048,7 +2053,11 @@ final class NativeMessageCell: UICollectionViewCell { } // Then check @mentions if let username = textLabel.textLayout?.mentionAt(point: pointInText) { - actions?.onMentionTap(username) + actions?.onMentionTap(username) { [weak self] found in + guard !found else { return } + self?.shakeMentionLabel() + UINotificationFeedbackGenerator().notificationOccurred(.error) + } return } } @@ -2359,6 +2368,17 @@ final class NativeMessageCell: UICollectionViewCell { } } + // MARK: - Mention Shake (invalid @username) + + /// Brief horizontal shake on the text label — Telegram parity for invalid @username. + func shakeMentionLabel() { + let anim = CAKeyframeAnimation(keyPath: "transform.translation.x") + anim.values = [0, -6, 6, -4, 4, -2, 2, 0] + anim.duration = 0.4 + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + textLabel.layer.add(anim, forKey: "mentionShake") + } + // MARK: - Highlight (scroll-to-message flash) func showHighlight() { @@ -3401,6 +3421,7 @@ final class NativeMessageCell: UICollectionViewCell { layer.removeAnimation(forKey: "skeletonPositionX") layer.removeAnimation(forKey: "skeletonPositionY") layer.removeAnimation(forKey: "skeletonFadeIn") + textLabel.layer.removeAnimation(forKey: "mentionShake") dateHeaderContainer.isHidden = true dateHeaderLabel.text = nil isInlineDateHeaderHidden = false diff --git a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift index 0c8ccf4..0bc7d2d 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeMessageList.swift @@ -91,6 +91,15 @@ final class NativeMessageListController: UIViewController { return Data(gk.utf8).map { String(format: "%02x", $0) }.joined() }() + // MARK: - Cached Theme (PERF: avoid UserDefaults read per layout) + + private var cachedIsDarkMode: Bool = true + + private func refreshCachedTheme() { + let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "system" + cachedIsDarkMode = themeMode != "light" + } + // MARK: - UIKit private var collectionView: UICollectionView! @@ -200,6 +209,7 @@ final class NativeMessageListController: UIViewController { override func viewDidLoad() { super.viewDidLoad() + refreshCachedTheme() setupCollectionView() setupNativeCellRegistration() setupDataSource() @@ -262,6 +272,7 @@ final class NativeMessageListController: UIViewController { let oldStyle = previousTraitCollection.userInterfaceStyle let newStyle = self.traitCollection.userInterfaceStyle guard oldStyle != newStyle else { return } + self.refreshCachedTheme() NativeMessageCell.regenerateBubbleImages(with: self.traitCollection) self.calculateLayouts() self.refreshAllMessageCells() @@ -528,9 +539,16 @@ final class NativeMessageListController: UIViewController { return f }() + /// PERF: Cache formatted timestamps — DateFormatter.string() is ~0.5ms per call. + /// With 15 visible cells at 60fps, uncached = 450ms/sec of CPU. + private var timestampCache: [Int64: String] = [:] + private func formatTimestamp(_ ms: Int64) -> String { + if let cached = timestampCache[ms] { return cached } let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000) - return Self.timestampFormatter.string(from: date) + let result = Self.timestampFormatter.string(from: date) + timestampCache[ms] = result + return result } /// Reply quote preview text for UIKit cells. @@ -1459,12 +1477,16 @@ final class NativeMessageListController: UIViewController { self.messages = messages - // Evict caches for messages no longer in the sliding window + // PERF: Evict only removed message IDs instead of filtering entire caches. + // Previous O(n) triple-filter ran on every update; now O(removed) only. if !layoutCache.isEmpty { let currentIds = Set(messages.map(\.id)) - layoutCache = layoutCache.filter { currentIds.contains($0.key) } - textLayoutCache = textLayoutCache.filter { currentIds.contains($0.key) } - replyDataCache = replyDataCache.filter { currentIds.contains($0.key) } + let staleKeys = layoutCache.keys.filter { !currentIds.contains($0) } + for key in staleKeys { + layoutCache.removeValue(forKey: key) + textLayoutCache.removeValue(forKey: key) + replyDataCache.removeValue(forKey: key) + } } // Layout calculation: sync for first load, async for subsequent updates. @@ -1623,8 +1645,7 @@ final class NativeMessageListController: UIViewController { textLayoutCache.removeAll() return } - let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "system" - let isDark = themeMode != "light" + let isDark = cachedIsDarkMode let (layouts, textLayouts) = MessageCellLayout.batchCalculate( messages: messages, maxBubbleWidth: config.maxBubbleWidth, @@ -1650,8 +1671,7 @@ final class NativeMessageListController: UIViewController { textLayoutCache.removeAll() return } - let themeMode = UserDefaults.standard.string(forKey: "rosetta_theme_mode") ?? "system" - let isDark = themeMode != "light" + let isDark = cachedIsDarkMode let request = LayoutEngine.LayoutRequest( messages: messages, @@ -1966,8 +1986,12 @@ extension NativeMessageListController: UICollectionViewDataSourcePrefetching { let attId = att.id // Skip if already in memory cache if AttachmentCache.shared.cachedImage(forAttachmentId: attId) != nil { continue } + // PERF: Use ImageLoadLimiter to cap concurrent disk I/O (max 3). + // Previous unbounded Task.detached launched 150+ tasks during fast scroll. Task.detached(priority: .utility) { + await ImageLoadLimiter.shared.acquire() let _ = AttachmentCache.shared.loadImage(forAttachmentId: attId) + await ImageLoadLimiter.shared.release() } } } diff --git a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift index 98eb9e1..8a5c444 100644 --- a/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift +++ b/Rosetta/Features/Chats/ChatDetail/NativeTextBubbleCell.swift @@ -18,6 +18,10 @@ final class NativeTextBubbleCell: UICollectionViewCell { private static let incomingColor = UIColor(red: 0.17, green: 0.17, blue: 0.18, alpha: 1) // #2B2B2E private static let replyQuoteHeight: CGFloat = 41 + // Cached checkmark images (Telegram-exact shapes) + private static let singleCheckImage: UIImage = StatusIconRenderer.renderShape(SingleCheckmarkShape(), size: CGSize(width: 12, height: 8.8)) + private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 15, height: 8)) + // MARK: - Subviews private let bubbleView = UIView() @@ -152,10 +156,13 @@ final class NativeTextBubbleCell: UICollectionViewCell { checkmarkView.isHidden = false switch message.deliveryStatus { case .delivered: - checkmarkView.image = UIImage(systemName: "checkmark")?.withRenderingMode(.alwaysTemplate) - checkmarkView.tintColor = message.isRead - ? UIColor.white - : UIColor.white.withAlphaComponent(0.55) + if message.isRead { + checkmarkView.image = Self.doubleCheckImage.withRenderingMode(.alwaysTemplate) + checkmarkView.tintColor = UIColor(red: 0xA4/255, green: 0xE2/255, blue: 0xFF/255, alpha: 1) + } else { + checkmarkView.image = Self.singleCheckImage.withRenderingMode(.alwaysTemplate) + checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55) + } case .waiting: checkmarkView.image = UIImage(systemName: "clock")?.withRenderingMode(.alwaysTemplate) checkmarkView.tintColor = UIColor.white.withAlphaComponent(0.55) @@ -248,7 +255,7 @@ final class NativeTextBubbleCell: UICollectionViewCell { // Timestamp + checkmark let tsSize = timestampLabel.sizeThatFits(CGSize(width: 60, height: 20)) - let checkW: CGFloat = isOutgoing ? 14 : 0 + let checkW: CGFloat = isOutgoing ? 16 : 0 timestampLabel.frame = CGRect( x: bubbleW - tsSize.width - checkW - 11, y: bubbleH - tsSize.height - 5, @@ -256,9 +263,9 @@ final class NativeTextBubbleCell: UICollectionViewCell { ) if isOutgoing { checkmarkView.frame = CGRect( - x: bubbleW - 11 - 10, + x: bubbleW - 11 - 14, y: bubbleH - tsSize.height - 4, - width: 10, height: 10 + width: 14, height: 10 ) } diff --git a/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift b/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift index ccdc259..1292f6d 100644 --- a/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift +++ b/Rosetta/Features/Chats/ChatDetail/StatusIconRenderer.swift @@ -1,7 +1,19 @@ import UIKit +import SwiftUI enum StatusIconRenderer { + /// Renders a SwiftUI Shape into a UIImage (black fill, template-ready). + static func renderShape(_ shape: S, size: CGSize) -> UIImage { + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { ctx in + let path = shape.path(in: CGRect(origin: .zero, size: size)) + ctx.cgContext.addPath(path.cgPath) + ctx.cgContext.setFillColor(UIColor.black.cgColor) + ctx.cgContext.fillPath() + } + } + static func makeCheckImage(partial: Bool, color: UIColor, width: CGFloat = 11) -> UIImage? { let height = floor(width * 9.0 / 11.0) let renderer = UIGraphicsImageRenderer(size: CGSize(width: width, height: height)) diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift index 2e679b9..b16ebeb 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCell.swift @@ -572,8 +572,8 @@ final class ChatListCell: UICollectionViewCell { // MARK: - Delivery Status /// Cached checkmark images (rendered once from SwiftUI Shapes). - private static let singleCheckImage: UIImage = renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3)) - private static let doubleCheckImage: UIImage = renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3)) + private static let singleCheckImage: UIImage = StatusIconRenderer.renderShape(SingleCheckmarkShape(), size: CGSize(width: 14, height: 10.3)) + private static let doubleCheckImage: UIImage = StatusIconRenderer.renderShape(DoubleCheckmarkShape(), size: CGSize(width: 17, height: 9.3)) /// Cached error indicator (Telegram: red circle with white exclamation). private static let errorImage: UIImage = { @@ -623,8 +623,13 @@ final class ChatListCell: UICollectionViewCell { statusImageView.isHidden = false statusImageView.image = Self.errorImage statusImageView.tintColor = nil + } else if dialog.lastMessageDelivered == .delivered { + // Delivered but not read — gray single checkmark + statusImageView.isHidden = false + statusImageView.image = Self.singleCheckImage.withRenderingMode(.alwaysTemplate) + statusImageView.tintColor = secondaryColor } else { - // Waiting / delivered but not read — hide (Telegram doesn't show in chat list) + // Waiting — hide (no server ACK yet) statusImageView.isHidden = true } } @@ -968,18 +973,6 @@ extension UIColor { // MARK: - Shape → UIImage Rendering -/// Renders a SwiftUI Shape into a UIImage (used for checkmarks). -private func renderShape(_ shape: S, size: CGSize) -> UIImage { - let renderer = UIGraphicsImageRenderer(size: size) - return renderer.image { ctx in - let path = shape.path(in: CGRect(origin: .zero, size: size)) - let cgPath = path.cgPath - ctx.cgContext.addPath(cgPath) - ctx.cgContext.setFillColor(UIColor.black.cgColor) - ctx.cgContext.fillPath() - } -} - /// Renders an SVG path string into a UIImage (used for verified badges). private func renderSVGPath(_ pathData: String, viewBox: CGSize, size: CGSize) -> UIImage { let renderer = UIGraphicsImageRenderer(size: size) diff --git a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift index 4a1531e..1502441 100644 --- a/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift +++ b/Rosetta/Features/Chats/ChatList/UIKit/ChatListCollectionController.swift @@ -77,8 +77,14 @@ final class ChatListCollectionController: UIViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) guard traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle else { return } - // Force all visible cells to redraw with new theme colors - collectionView.reloadData() + // PERF: Reconfigure only visible cells instead of full reloadData(). + // reloadData() discards all cell state and rebuilds everything. + if let visiblePaths = collectionView.indexPathsForVisibleItems as? [IndexPath], !visiblePaths.isEmpty { + var snapshot = dataSource.snapshot() + let visibleIds = visiblePaths.compactMap { dataSource.itemIdentifier(for: $0) } + snapshot.reconfigureItems(visibleIds) + dataSource.apply(snapshot, animatingDifferences: false) + } } deinit { @@ -626,14 +632,24 @@ extension ChatListCollectionController: UICollectionViewDelegate { // MARK: - UICollectionViewDataSourcePrefetching extension ChatListCollectionController: UICollectionViewDataSourcePrefetching { + + /// PERF: Shared serial queue limits concurrent avatar decrypt to 1 at a time. + /// Previous implementation launched unbounded concurrent tasks (50+) during scroll. + private static let avatarPrefetchQueue: OperationQueue = { + let q = OperationQueue() + q.maxConcurrentOperationCount = 3 + q.qualityOfService = .utility + q.name = "com.rosetta.avatar-prefetch" + return q + }() + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { for indexPath in indexPaths { guard let itemId = dataSource.itemIdentifier(for: indexPath), let dialog = dialogMap[itemId], !dialog.isSavedMessages else { continue } - // Warm avatar cache on background queue let key = dialog.opponentKey - DispatchQueue.global(qos: .userInitiated).async { + Self.avatarPrefetchQueue.addOperation { _ = AvatarRepository.shared.loadAvatar(publicKey: key) } }