From 1cdd392cf3bedf4b0dd74dcb0695ba1a3a243c7c Mon Sep 17 00:00:00 2001 From: senseiGai Date: Mon, 23 Mar 2026 19:46:01 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BA=D1=81:=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=82=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=20=D1=81=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=BA=D0=BB=D0=B0=D0=B2=D0=B8=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Rosetta.xcodeproj/project.pbxproj | 8 +- .../Core/Data/Models/AttachmentCache.swift | 83 ++++++++++++++++++- .../Components/ChatTextInput.swift | 13 +++ .../ComposerContainerController.swift | 7 ++ .../Components/KeyboardTracker.swift | 61 ++++++++++++-- .../Chats/ChatDetail/ChatDetailView.swift | 10 ++- .../Chats/ChatDetail/MessageAvatarView.swift | 48 +++++++---- .../Chats/ChatDetail/MessageImageView.swift | 46 ++++++---- 8 files changed, 231 insertions(+), 45 deletions(-) diff --git a/Rosetta.xcodeproj/project.pbxproj b/Rosetta.xcodeproj/project.pbxproj index 82a4e16..019f449 100644 --- a/Rosetta.xcodeproj/project.pbxproj +++ b/Rosetta.xcodeproj/project.pbxproj @@ -421,7 +421,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -437,7 +437,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -460,7 +460,7 @@ CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 25; DEVELOPMENT_TEAM = QN8Z263QGX; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -476,7 +476,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.3; + MARKETING_VERSION = 1.2.4; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Rosetta/Core/Data/Models/AttachmentCache.swift b/Rosetta/Core/Data/Models/AttachmentCache.swift index e52c3ac..87a6bdb 100644 --- a/Rosetta/Core/Data/Models/AttachmentCache.swift +++ b/Rosetta/Core/Data/Models/AttachmentCache.swift @@ -1,6 +1,38 @@ import UIKit +import ImageIO import os +// MARK: - ImageLoadLimiter + +/// Android parity: `ImageLoadSemaphore` in `AttachmentComponents.kt`. +/// Limits concurrent image disk I/O + decode operations to 3, +/// preventing scroll lag when collages (4–5 photos) appear simultaneously. +actor ImageLoadLimiter { + static let shared = ImageLoadLimiter() + private let maxConcurrent = 3 + private var running = 0 + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if running < maxConcurrent { + running += 1 + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + running -= 1 + } + } +} + // MARK: - AttachmentCache /// Local disk cache for downloaded/decrypted attachment images and files. @@ -18,13 +50,28 @@ final class AttachmentCache: @unchecked Sendable { private let cacheDir: URL + /// In-memory image cache — eliminates disk I/O + crypto on scroll-back. + /// Android parity: LruCache in AttachmentFileManager.kt. + /// Same pattern as AvatarRepository.cache (NSCache is thread-safe). + private let imageCache: NSCache = { + let cache = NSCache() + cache.countLimit = 100 + cache.totalCostLimit = 80 * 1024 * 1024 // 80 MB — auto-evicts under memory pressure + return cache + }() + /// Private key for encrypting files at rest (Android parity). /// Set from SessionManager.startSession() after unlocking account. private let keyLock = NSLock() private var _privateKey: String? var privateKey: String? { get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey } - set { keyLock.lock(); _privateKey = newValue; keyLock.unlock() } + set { + keyLock.lock() + _privateKey = newValue + keyLock.unlock() + if newValue == nil { imageCache.removeAllObjects() } + } } private init() { @@ -33,11 +80,36 @@ final class AttachmentCache: @unchecked Sendable { try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } + // MARK: - Downsampled Decode + + /// Android parity: `BitmapFactory.Options.inSampleSize` with max 4096px. + /// Uses `CGImageSource` for memory-efficient downsampled decoding + EXIF orientation. + /// `kCGImageSourceShouldCacheImmediately` forces decode now (not lazily on first draw). + static func downsampledImage(from data: Data, maxPixelSize: Int = 4096) -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil) else { + return UIImage(data: data) + } + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize + ] + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { + return UIImage(data: data) + } + return UIImage(cgImage: cgImage) + } + // MARK: - Images /// Saves a decoded image to cache, encrypted with private key (Android parity). func saveImage(_ image: UIImage, forAttachmentId id: String) { guard let data = image.jpegData(compressionQuality: 0.95) else { return } + + // Warm in-memory cache immediately — next loadImage() returns in O(1) + imageCache.setObject(image, forKey: id as NSString, cost: data.count) + let url = cacheDir.appendingPathComponent("img_\(id).enc") if let key = privateKey, @@ -53,6 +125,11 @@ final class AttachmentCache: @unchecked Sendable { /// Loads a cached image for an attachment ID, or `nil` if not cached. func loadImage(forAttachmentId id: String) -> UIImage? { + // Fast path: in-memory cache hit — no disk I/O, no crypto + if let cached = imageCache.object(forKey: id as NSString) { + return cached + } + // Try encrypted format first let encUrl = cacheDir.appendingPathComponent("img_\(id).enc") if FileManager.default.fileExists(atPath: encUrl.path), @@ -60,7 +137,8 @@ final class AttachmentCache: @unchecked Sendable { let encString = String(data: fileData, encoding: .utf8), let key = privateKey, let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key), - let image = UIImage(data: decrypted) { + let image = Self.downsampledImage(from: decrypted) { + imageCache.setObject(image, forKey: id as NSString, cost: decrypted.count) return image } @@ -134,6 +212,7 @@ final class AttachmentCache: @unchecked Sendable { // MARK: - Cleanup func clearAll() { + imageCache.removeAllObjects() try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) } diff --git a/Rosetta/DesignSystem/Components/ChatTextInput.swift b/Rosetta/DesignSystem/Components/ChatTextInput.swift index 9dd0c15..5c1cb9e 100644 --- a/Rosetta/DesignSystem/Components/ChatTextInput.swift +++ b/Rosetta/DesignSystem/Components/ChatTextInput.swift @@ -111,6 +111,7 @@ struct ChatTextInput: UIViewRepresentable { @Binding var isFocused: Bool var onKeyboardHeightChange: (CGFloat) -> Void var onUserTextInsertion: () -> Void = {} + var onMultilineChange: (Bool) -> Void = { _ in } var font: UIFont = .systemFont(ofSize: 17, weight: .regular) var textColor: UIColor = .white var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35) @@ -195,6 +196,7 @@ struct ChatTextInput: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate { var parent: ChatTextInput var isUpdatingText = false + private var wasMultiline = false private var pendingFocusSync: DispatchWorkItem? init(parent: ChatTextInput) { @@ -248,6 +250,17 @@ struct ChatTextInput: UIViewRepresentable { func invalidateHeight(_ tv: UITextView) { tv.invalidateIntrinsicContentSize() + checkMultiline(tv) + } + + private func checkMultiline(_ tv: UITextView) { + let lineHeight = tv.font?.lineHeight ?? 20 + let singleLineHeight = lineHeight + tv.textContainerInset.top + tv.textContainerInset.bottom + let isMultiline = tv.contentSize.height > singleLineHeight + 0.5 + if isMultiline != wasMultiline { + wasMultiline = isMultiline + parent.onMultilineChange(isMultiline) + } } func syncFocus(for tv: UITextView) { diff --git a/Rosetta/DesignSystem/Components/ComposerContainerController.swift b/Rosetta/DesignSystem/Components/ComposerContainerController.swift index 7bb501a..9471572 100644 --- a/Rosetta/DesignSystem/Components/ComposerContainerController.swift +++ b/Rosetta/DesignSystem/Components/ComposerContainerController.swift @@ -51,6 +51,9 @@ final class ComposerHostView: UIView { backgroundColor = .clear hostingController.view.backgroundColor = .clear + if #available(iOS 16.0, *) { + hostingController.sizingOptions = .intrinsicContentSize + } if #available(iOS 16.4, *) { hostingController.safeAreaRegions = [] } @@ -78,6 +81,10 @@ final class ComposerHostView: UIView { func updateContent(_ content: AnyView) { hostingController.rootView = content + // Force hosting controller to re-measure after content change — + // nested UIViewRepresentable (ChatTextInput) size changes may not + // propagate through the hosting controller automatically. + hostingController.view.invalidateIntrinsicContentSize() } /// Moves composer up by `offset` points from the bottom. diff --git a/Rosetta/DesignSystem/Components/KeyboardTracker.swift b/Rosetta/DesignSystem/Components/KeyboardTracker.swift index eac1c48..37da665 100644 --- a/Rosetta/DesignSystem/Components/KeyboardTracker.swift +++ b/Rosetta/DesignSystem/Components/KeyboardTracker.swift @@ -89,6 +89,12 @@ final class KeyboardTracker: ObservableObject { .publisher(for: UIResponder.keyboardWillChangeFrameNotification) .sink { [weak self] in self?.handleNotification($0) } .store(in: &cancellables) + + // Pre-create display link (paused) — avoids allocation overhead on first keyboard show. + displayLinkProxy = DisplayLinkProxy { [weak self] in + self?.animationTick() + } + displayLinkProxy?.isPaused = true } /// Sets keyboardPadding with animation matching keyboard duration. @@ -146,8 +152,9 @@ final class KeyboardTracker: ObservableObject { if self.keyboardPadding != 0 { self.keyboardPadding = 0 } - // spacerPadding NOT reset here — only from handleNotification - // with withAnimation to avoid jumps during rapid toggling. + if self.spacerPadding != 0 { + self.spacerPadding = 0 + } } } } @@ -166,6 +173,9 @@ final class KeyboardTracker: ObservableObject { let current = pendingKVOPadding ?? keyboardPadding guard newPadding < current else { return } + // Drop spacerPadding floor so max() follows keyboardPadding during dismiss + if spacerPadding != 0 { spacerPadding = 0 } + // Move composer immediately (UIKit, no SwiftUI overhead) composerHostView?.setKeyboardOffset(rawPadding) @@ -235,6 +245,24 @@ final class KeyboardTracker: ObservableObject { let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 + // Configure bezier early — needed for curve-aware initial spacerPadding below. + configureBezier(curveRaw: curveRaw) + + // Direction-dependent spacerPadding initialization: + let isShow = targetPadding > keyboardPadding + if isShow { + // Curve-aware head start: evaluate bezier at ~1 frame into animation. + // Covers the gap before first CADisplayLink tick fires (~8ms on 120Hz, ~16ms on 60Hz). + // Smooth (follows curve shape), not a jarring jump. + let initialT = min(0.016 / max(duration, 0.05), 1.0) + let initialEased = cubicBezierEase(initialT) + let initialValue = keyboardPadding + (targetPadding - keyboardPadding) * initialEased + spacerPadding = max(keyboardPadding, round(initialValue)) + } else { + // HIDE: instant drop, keyboardPadding drives smooth descent + spacerPadding = 0 + } + let delta = targetPadding - lastNotificationPadding lastNotificationPadding = targetPadding @@ -331,17 +359,16 @@ final class KeyboardTracker: ObservableObject { lastEased = 0 previousMonotonicEased = 0 + // Always configure bezier — needed for spacerPadding advance computation + // even when sync view is the primary easing source. + configureBezier(curveRaw: curveRaw) + // Primary: sync view matches keyboard's exact curve (same CA transaction). let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw) #if DEBUG print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s") #endif - // Fallback: cubic bezier (only if sync view can't be created). - if !syncOK { - configureBezier(curveRaw: curveRaw) - } - // Reuse existing display link to preserve vsync phase alignment. if let proxy = displayLinkProxy { proxy.isPaused = false @@ -423,7 +450,7 @@ final class KeyboardTracker: ObservableObject { if animTickCount > 1 { let velocity = eased - previousMonotonicEased if velocity > 0 { - eased = min(eased + velocity, 1.0) + eased = min(eased + velocity * 1.0, 1.0) } } previousMonotonicEased = rawEased @@ -452,6 +479,7 @@ final class KeyboardTracker: ObservableObject { if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) { let prevPadding = keyboardPadding keyboardPadding = max(0, animTargetPadding) + spacerPadding = max(0, animTargetPadding) // Pause instead of invalidate — preserves vsync phase for next animation. displayLinkProxy?.isPaused = true lastTickTime = 0 @@ -473,6 +501,23 @@ final class KeyboardTracker: ObservableObject { print("⌨️ T\(animTickCount) | syncOp=\(syncOpacity.map { String(format: "%.3f", $0) } ?? "nil") eased=\(String(format: "%.4f", eased)) | pad \(Int(prevPad))→\(Int(rounded)) Δ\(Int(rounded - prevPad))pt | \(String(format: "%.1f", elapsed * 1000))ms\(gliding ? " 🛬" : "")") #endif } + + // Time-advanced spacerPadding: leads keyboardPadding by ~1 frame. + // Prevents compositor-message overlap during SHOW — UIKit composer has + // zero lag (same CA transaction), but SwiftUI spacer has 15-25ms render + // delay. Without this advance, messages lag behind the rising composer. + if animTargetPadding > animStartPadding { + let advanceMs: CFTimeInterval = 0.016 + let advancedT = min((elapsed + advanceMs) / animDuration, 1.0) + let advancedEased = cubicBezierEase(advancedT) + let advancedRaw = animStartPadding + (animTargetPadding - animStartPadding) * advancedEased + let advancedRounded = max(0, round(advancedRaw)) + // Monotonic: only increase during SHOW (no jitter from bezier approximation) + if advancedRounded > spacerPadding { + spacerPadding = advancedRounded + } + } + } // MARK: - Cubic bezier fallback diff --git a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift index 1c61b1f..03b0067 100644 --- a/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift +++ b/Rosetta/Features/Chats/ChatDetail/ChatDetailView.swift @@ -27,7 +27,7 @@ private struct KeyboardSpacer: View { // Inverted scroll: spacer at VStack START. Growing it pushes // messages away from offset=0 → visually UP. CADisplayLink // animates keyboardPadding in sync with keyboard curve. - return composerHeight + keyboard.keyboardPadding + 4 + return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8 } }() #if DEBUG @@ -88,6 +88,7 @@ struct ChatDetailView: View { } @State private var messageText = "" + @State private var isMultilineInput = false @State private var sendError: String? @State private var isViewActive = false // markReadTask removed — read receipts no longer sent from .onChange(of: messages.count) @@ -1598,6 +1599,11 @@ private extension ChatDetailView { KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) }, onUserTextInsertion: handleComposerUserTyping, + onMultilineChange: { multiline in + withAnimation(.easeInOut(duration: 0.2)) { + isMultilineInput = multiline + } + }, textColor: UIColor(RosettaColors.Adaptive.text), placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5)) ) @@ -1652,7 +1658,7 @@ private extension ChatDetailView { } .padding(3) .frame(minHeight: 42, alignment: .bottom) - .background { glass(shape: .rounded(21), strokeOpacity: 0.18) } + .background { glass(shape: .rounded(isMultilineInput ? 16 : 21), strokeOpacity: 0.18) } .padding(.leading, 6) Button(action: trailingAction) { diff --git a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift index f9e8795..5412d5b 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageAvatarView.swift @@ -92,9 +92,9 @@ struct MessageAvatarView: View { } } .task { - loadFromCache() + await loadFromCache() if avatarImage == nil { - decodeBlurHash() + await decodeBlurHash() } } } @@ -169,21 +169,25 @@ struct MessageAvatarView: View { /// Shared static cache with half-eviction (same pattern as MessageImageView). @MainActor private static var blurHashCache: [String: UIImage] = [:] - private func decodeBlurHash() { + private func decodeBlurHash() async { let hash = extractBlurHash(from: attachment.preview) guard !hash.isEmpty else { return } + // Fast path: cache hit (synchronous) if let cached = Self.blurHashCache[hash] { blurImage = cached return } - if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { - if Self.blurHashCache.count > 200 { - let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) - for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } - } - Self.blurHashCache[hash] = result - blurImage = result + // Slow path: DCT decode off main thread + let result = await Task.detached(priority: .userInitiated) { + UIImage.fromBlurHash(hash, width: 32, height: 32) + }.value + guard !Task.isCancelled, let result else { return } + if Self.blurHashCache.count > 200 { + let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) + for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } } + Self.blurHashCache[hash] = result + blurImage = result } /// Extracts the blurhash from preview string. @@ -195,12 +199,26 @@ struct MessageAvatarView: View { // MARK: - Download - private func loadFromCache() { + private func loadFromCache() async { + // Fast path: NSCache hit (synchronous, sub-microsecond) if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { avatarImage = cached showAvatar = true // No animation for cached — show immediately return } + // Slow path: disk I/O + crypto — run off main thread with semaphore + // Android parity: ImageLoadSemaphore limits to 3 concurrent decode ops + let attachmentId = attachment.id + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .userInitiated) { + AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + }.value + await ImageLoadLimiter.shared.release() + if !Task.isCancelled, let loaded { + avatarImage = loaded + showAvatar = true + return + } // Outgoing avatar: sender is me — load from AvatarRepository (always available locally) if outgoing { let myKey = SessionManager.shared.currentPublicKey @@ -283,22 +301,22 @@ struct MessageAvatarView: View { return nil } - /// Parses decrypted data as an image: data URI, plain base64, or raw image bytes. + /// Android parity: `base64ToBitmap()` with subsampling to max 4096px. private func parseImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8) { if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { let base64Part = String(str[str.index(after: commaIndex)...]) if let imageData = Data(base64Encoded: base64Part), - let img = UIImage(data: imageData) { + let img = AttachmentCache.downsampledImage(from: imageData) { return img } } else if let imageData = Data(base64Encoded: str), - let img = UIImage(data: imageData) { + let img = AttachmentCache.downsampledImage(from: imageData) { return img } } - return UIImage(data: data) + return AttachmentCache.downsampledImage(from: data) } /// Extracts the server tag from preview string. diff --git a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift index c335432..92fa9c6 100644 --- a/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift +++ b/Rosetta/Features/Chats/ChatDetail/MessageImageView.swift @@ -66,9 +66,9 @@ struct MessageImageView: View { } } .task { - loadFromCache() + await loadFromCache() if image == nil { - decodeBlurHash() + await decodeBlurHash() } } .onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in @@ -205,29 +205,46 @@ struct MessageImageView: View { @MainActor private static var blurHashCache: [String: UIImage] = [:] - private func decodeBlurHash() { + private func decodeBlurHash() async { let hash = extractBlurHash(from: attachment.preview) guard !hash.isEmpty else { return } + // Fast path: cache hit (synchronous) if let cached = Self.blurHashCache[hash] { blurImage = cached return } - if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { - if Self.blurHashCache.count > 200 { - let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) - for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } - } - Self.blurHashCache[hash] = result - blurImage = result + // Slow path: DCT decode off main thread + let result = await Task.detached(priority: .userInitiated) { + UIImage.fromBlurHash(hash, width: 32, height: 32) + }.value + guard !Task.isCancelled, let result else { return } + if Self.blurHashCache.count > 200 { + let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) + for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } } + Self.blurHashCache[hash] = result + blurImage = result } // MARK: - Download - private func loadFromCache() { + private func loadFromCache() async { PerformanceLogger.shared.track("image.cacheLoad") + // Fast path: NSCache hit (synchronous, sub-microsecond) if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { image = cached + return + } + // Slow path: disk I/O + crypto — run off main thread with semaphore + // Android parity: ImageLoadSemaphore limits to 3 concurrent decode ops + let attachmentId = attachment.id + await ImageLoadLimiter.shared.acquire() + let loaded = await Task.detached(priority: .userInitiated) { + AttachmentCache.shared.loadImage(forAttachmentId: attachmentId) + }.value + await ImageLoadLimiter.shared.release() + if !Task.isCancelled, let loaded { + image = loaded } } @@ -293,21 +310,22 @@ struct MessageImageView: View { return nil } + /// Android parity: `base64ToBitmap()` with subsampling to max 4096px. private func parseImageData(_ data: Data) -> UIImage? { if let str = String(data: data, encoding: .utf8) { if str.hasPrefix("data:"), let commaIndex = str.firstIndex(of: ",") { let base64Part = String(str[str.index(after: commaIndex)...]) if let imageData = Data(base64Encoded: base64Part), - let img = UIImage(data: imageData) { + let img = AttachmentCache.downsampledImage(from: imageData) { return img } } else if let imageData = Data(base64Encoded: str), - let img = UIImage(data: imageData) { + let img = AttachmentCache.downsampledImage(from: imageData) { return img } } - return UIImage(data: data) + return AttachmentCache.downsampledImage(from: data) } // MARK: - Preview Parsing