Фикс: поднятие контента сообщений синхронно с раскрытием клавиатуры

This commit is contained in:
2026-03-23 19:46:01 +05:00
parent 0b95776968
commit 1cdd392cf3
8 changed files with 231 additions and 45 deletions

View File

@@ -421,7 +421,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -437,7 +437,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.3; MARKETING_VERSION = 1.2.4;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -460,7 +460,7 @@
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements; CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24; CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = QN8Z263QGX; DEVELOPMENT_TEAM = QN8Z263QGX;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.2.3; MARKETING_VERSION = 1.2.4;
PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev; PRODUCT_BUNDLE_IDENTIFIER = com.rosetta.dev;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";

View File

@@ -1,6 +1,38 @@
import UIKit import UIKit
import ImageIO
import os 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 (45 photos) appear simultaneously.
actor ImageLoadLimiter {
static let shared = ImageLoadLimiter()
private let maxConcurrent = 3
private var running = 0
private var waiters: [CheckedContinuation<Void, Never>] = []
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 // MARK: - AttachmentCache
/// Local disk cache for downloaded/decrypted attachment images and files. /// Local disk cache for downloaded/decrypted attachment images and files.
@@ -18,13 +50,28 @@ final class AttachmentCache: @unchecked Sendable {
private let cacheDir: URL 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<NSString, UIImage> = {
let cache = NSCache<NSString, UIImage>()
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). /// Private key for encrypting files at rest (Android parity).
/// Set from SessionManager.startSession() after unlocking account. /// Set from SessionManager.startSession() after unlocking account.
private let keyLock = NSLock() private let keyLock = NSLock()
private var _privateKey: String? private var _privateKey: String?
var privateKey: String? { var privateKey: String? {
get { keyLock.lock(); defer { keyLock.unlock() }; return _privateKey } 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() { private init() {
@@ -33,11 +80,36 @@ final class AttachmentCache: @unchecked Sendable {
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) 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 // MARK: - Images
/// Saves a decoded image to cache, encrypted with private key (Android parity). /// Saves a decoded image to cache, encrypted with private key (Android parity).
func saveImage(_ image: UIImage, forAttachmentId id: String) { func saveImage(_ image: UIImage, forAttachmentId id: String) {
guard let data = image.jpegData(compressionQuality: 0.95) else { return } 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") let url = cacheDir.appendingPathComponent("img_\(id).enc")
if let key = privateKey, 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. /// Loads a cached image for an attachment ID, or `nil` if not cached.
func loadImage(forAttachmentId id: String) -> UIImage? { 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 // Try encrypted format first
let encUrl = cacheDir.appendingPathComponent("img_\(id).enc") let encUrl = cacheDir.appendingPathComponent("img_\(id).enc")
if FileManager.default.fileExists(atPath: encUrl.path), if FileManager.default.fileExists(atPath: encUrl.path),
@@ -60,7 +137,8 @@ final class AttachmentCache: @unchecked Sendable {
let encString = String(data: fileData, encoding: .utf8), let encString = String(data: fileData, encoding: .utf8),
let key = privateKey, let key = privateKey,
let decrypted = try? CryptoManager.shared.decryptWithPassword(encString, password: key), 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 return image
} }
@@ -134,6 +212,7 @@ final class AttachmentCache: @unchecked Sendable {
// MARK: - Cleanup // MARK: - Cleanup
func clearAll() { func clearAll() {
imageCache.removeAllObjects()
try? FileManager.default.removeItem(at: cacheDir) try? FileManager.default.removeItem(at: cacheDir)
try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
} }

View File

@@ -111,6 +111,7 @@ struct ChatTextInput: UIViewRepresentable {
@Binding var isFocused: Bool @Binding var isFocused: Bool
var onKeyboardHeightChange: (CGFloat) -> Void var onKeyboardHeightChange: (CGFloat) -> Void
var onUserTextInsertion: () -> Void = {} var onUserTextInsertion: () -> Void = {}
var onMultilineChange: (Bool) -> Void = { _ in }
var font: UIFont = .systemFont(ofSize: 17, weight: .regular) var font: UIFont = .systemFont(ofSize: 17, weight: .regular)
var textColor: UIColor = .white var textColor: UIColor = .white
var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35) var placeholderColor: UIColor = UIColor.white.withAlphaComponent(0.35)
@@ -195,6 +196,7 @@ struct ChatTextInput: UIViewRepresentable {
final class Coordinator: NSObject, UITextViewDelegate { final class Coordinator: NSObject, UITextViewDelegate {
var parent: ChatTextInput var parent: ChatTextInput
var isUpdatingText = false var isUpdatingText = false
private var wasMultiline = false
private var pendingFocusSync: DispatchWorkItem? private var pendingFocusSync: DispatchWorkItem?
init(parent: ChatTextInput) { init(parent: ChatTextInput) {
@@ -248,6 +250,17 @@ struct ChatTextInput: UIViewRepresentable {
func invalidateHeight(_ tv: UITextView) { func invalidateHeight(_ tv: UITextView) {
tv.invalidateIntrinsicContentSize() 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) { func syncFocus(for tv: UITextView) {

View File

@@ -51,6 +51,9 @@ final class ComposerHostView: UIView {
backgroundColor = .clear backgroundColor = .clear
hostingController.view.backgroundColor = .clear hostingController.view.backgroundColor = .clear
if #available(iOS 16.0, *) {
hostingController.sizingOptions = .intrinsicContentSize
}
if #available(iOS 16.4, *) { if #available(iOS 16.4, *) {
hostingController.safeAreaRegions = [] hostingController.safeAreaRegions = []
} }
@@ -78,6 +81,10 @@ final class ComposerHostView: UIView {
func updateContent(_ content: AnyView) { func updateContent(_ content: AnyView) {
hostingController.rootView = content 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. /// Moves composer up by `offset` points from the bottom.

View File

@@ -89,6 +89,12 @@ final class KeyboardTracker: ObservableObject {
.publisher(for: UIResponder.keyboardWillChangeFrameNotification) .publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) } .sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables) .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. /// Sets keyboardPadding with animation matching keyboard duration.
@@ -146,8 +152,9 @@ final class KeyboardTracker: ObservableObject {
if self.keyboardPadding != 0 { if self.keyboardPadding != 0 {
self.keyboardPadding = 0 self.keyboardPadding = 0
} }
// spacerPadding NOT reset here only from handleNotification if self.spacerPadding != 0 {
// with withAnimation to avoid jumps during rapid toggling. self.spacerPadding = 0
}
} }
} }
} }
@@ -166,6 +173,9 @@ final class KeyboardTracker: ObservableObject {
let current = pendingKVOPadding ?? keyboardPadding let current = pendingKVOPadding ?? keyboardPadding
guard newPadding < current else { return } 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) // Move composer immediately (UIKit, no SwiftUI overhead)
composerHostView?.setKeyboardOffset(rawPadding) composerHostView?.setKeyboardOffset(rawPadding)
@@ -235,6 +245,24 @@ final class KeyboardTracker: ObservableObject {
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25 let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0 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 let delta = targetPadding - lastNotificationPadding
lastNotificationPadding = targetPadding lastNotificationPadding = targetPadding
@@ -331,17 +359,16 @@ final class KeyboardTracker: ObservableObject {
lastEased = 0 lastEased = 0
previousMonotonicEased = 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). // Primary: sync view matches keyboard's exact curve (same CA transaction).
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw) let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
#if DEBUG #if DEBUG
print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s") print("⌨️ 🎬 START | from=\(Int(keyboardPadding)) to=\(Int(target)) syncOK=\(syncOK) dur=\(String(format: "%.3f", duration))s")
#endif #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. // Reuse existing display link to preserve vsync phase alignment.
if let proxy = displayLinkProxy { if let proxy = displayLinkProxy {
proxy.isPaused = false proxy.isPaused = false
@@ -423,7 +450,7 @@ final class KeyboardTracker: ObservableObject {
if animTickCount > 1 { if animTickCount > 1 {
let velocity = eased - previousMonotonicEased let velocity = eased - previousMonotonicEased
if velocity > 0 { if velocity > 0 {
eased = min(eased + velocity, 1.0) eased = min(eased + velocity * 1.0, 1.0)
} }
} }
previousMonotonicEased = rawEased previousMonotonicEased = rawEased
@@ -452,6 +479,7 @@ final class KeyboardTracker: ObservableObject {
if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) { if hardDeadline || animTickCount > 40 || (pastDuration && closeEnough) {
let prevPadding = keyboardPadding let prevPadding = keyboardPadding
keyboardPadding = max(0, animTargetPadding) keyboardPadding = max(0, animTargetPadding)
spacerPadding = max(0, animTargetPadding)
// Pause instead of invalidate preserves vsync phase for next animation. // Pause instead of invalidate preserves vsync phase for next animation.
displayLinkProxy?.isPaused = true displayLinkProxy?.isPaused = true
lastTickTime = 0 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 ? " 🛬" : "")") 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 #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 // MARK: - Cubic bezier fallback

View File

@@ -27,7 +27,7 @@ private struct KeyboardSpacer: View {
// Inverted scroll: spacer at VStack START. Growing it pushes // Inverted scroll: spacer at VStack START. Growing it pushes
// messages away from offset=0 visually UP. CADisplayLink // messages away from offset=0 visually UP. CADisplayLink
// animates keyboardPadding in sync with keyboard curve. // animates keyboardPadding in sync with keyboard curve.
return composerHeight + keyboard.keyboardPadding + 4 return composerHeight + max(keyboard.keyboardPadding, keyboard.spacerPadding) + 8
} }
}() }()
#if DEBUG #if DEBUG
@@ -88,6 +88,7 @@ struct ChatDetailView: View {
} }
@State private var messageText = "" @State private var messageText = ""
@State private var isMultilineInput = false
@State private var sendError: String? @State private var sendError: String?
@State private var isViewActive = false @State private var isViewActive = false
// markReadTask removed read receipts no longer sent from .onChange(of: messages.count) // markReadTask removed read receipts no longer sent from .onChange(of: messages.count)
@@ -1598,6 +1599,11 @@ private extension ChatDetailView {
KeyboardTracker.shared.updateFromKVO(keyboardHeight: height) KeyboardTracker.shared.updateFromKVO(keyboardHeight: height)
}, },
onUserTextInsertion: handleComposerUserTyping, onUserTextInsertion: handleComposerUserTyping,
onMultilineChange: { multiline in
withAnimation(.easeInOut(duration: 0.2)) {
isMultilineInput = multiline
}
},
textColor: UIColor(RosettaColors.Adaptive.text), textColor: UIColor(RosettaColors.Adaptive.text),
placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5)) placeholderColor: UIColor(RosettaColors.Adaptive.textSecondary.opacity(0.5))
) )
@@ -1652,7 +1658,7 @@ private extension ChatDetailView {
} }
.padding(3) .padding(3)
.frame(minHeight: 42, alignment: .bottom) .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) .padding(.leading, 6)
Button(action: trailingAction) { Button(action: trailingAction) {

View File

@@ -92,9 +92,9 @@ struct MessageAvatarView: View {
} }
} }
.task { .task {
loadFromCache() await loadFromCache()
if avatarImage == nil { if avatarImage == nil {
decodeBlurHash() await decodeBlurHash()
} }
} }
} }
@@ -169,21 +169,25 @@ struct MessageAvatarView: View {
/// Shared static cache with half-eviction (same pattern as MessageImageView). /// Shared static cache with half-eviction (same pattern as MessageImageView).
@MainActor private static var blurHashCache: [String: UIImage] = [:] @MainActor private static var blurHashCache: [String: UIImage] = [:]
private func decodeBlurHash() { private func decodeBlurHash() async {
let hash = extractBlurHash(from: attachment.preview) let hash = extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return } guard !hash.isEmpty else { return }
// Fast path: cache hit (synchronous)
if let cached = Self.blurHashCache[hash] { if let cached = Self.blurHashCache[hash] {
blurImage = cached blurImage = cached
return return
} }
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { // Slow path: DCT decode off main thread
if Self.blurHashCache.count > 200 { let result = await Task.detached(priority: .userInitiated) {
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) UIImage.fromBlurHash(hash, width: 32, height: 32)
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } }.value
} guard !Task.isCancelled, let result else { return }
Self.blurHashCache[hash] = result if Self.blurHashCache.count > 200 {
blurImage = result 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. /// Extracts the blurhash from preview string.
@@ -195,12 +199,26 @@ struct MessageAvatarView: View {
// MARK: - Download // 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) { if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
avatarImage = cached avatarImage = cached
showAvatar = true // No animation for cached show immediately showAvatar = true // No animation for cached show immediately
return 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) // Outgoing avatar: sender is me load from AvatarRepository (always available locally)
if outgoing { if outgoing {
let myKey = SessionManager.shared.currentPublicKey let myKey = SessionManager.shared.currentPublicKey
@@ -283,22 +301,22 @@ struct MessageAvatarView: View {
return nil 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? { private func parseImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8) { if let str = String(data: data, encoding: .utf8) {
if str.hasPrefix("data:"), if str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") { let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...]) let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part), if let imageData = Data(base64Encoded: base64Part),
let img = UIImage(data: imageData) { let img = AttachmentCache.downsampledImage(from: imageData) {
return img return img
} }
} else if let imageData = Data(base64Encoded: str), } else if let imageData = Data(base64Encoded: str),
let img = UIImage(data: imageData) { let img = AttachmentCache.downsampledImage(from: imageData) {
return img return img
} }
} }
return UIImage(data: data) return AttachmentCache.downsampledImage(from: data)
} }
/// Extracts the server tag from preview string. /// Extracts the server tag from preview string.

View File

@@ -66,9 +66,9 @@ struct MessageImageView: View {
} }
} }
.task { .task {
loadFromCache() await loadFromCache()
if image == nil { if image == nil {
decodeBlurHash() await decodeBlurHash()
} }
} }
.onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in .onReceive(NotificationCenter.default.publisher(for: .triggerAttachmentDownload)) { notif in
@@ -205,29 +205,46 @@ struct MessageImageView: View {
@MainActor private static var blurHashCache: [String: UIImage] = [:] @MainActor private static var blurHashCache: [String: UIImage] = [:]
private func decodeBlurHash() { private func decodeBlurHash() async {
let hash = extractBlurHash(from: attachment.preview) let hash = extractBlurHash(from: attachment.preview)
guard !hash.isEmpty else { return } guard !hash.isEmpty else { return }
// Fast path: cache hit (synchronous)
if let cached = Self.blurHashCache[hash] { if let cached = Self.blurHashCache[hash] {
blurImage = cached blurImage = cached
return return
} }
if let result = UIImage.fromBlurHash(hash, width: 32, height: 32) { // Slow path: DCT decode off main thread
if Self.blurHashCache.count > 200 { let result = await Task.detached(priority: .userInitiated) {
let keysToRemove = Array(Self.blurHashCache.keys.prefix(100)) UIImage.fromBlurHash(hash, width: 32, height: 32)
for key in keysToRemove { Self.blurHashCache.removeValue(forKey: key) } }.value
} guard !Task.isCancelled, let result else { return }
Self.blurHashCache[hash] = result if Self.blurHashCache.count > 200 {
blurImage = result 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 // MARK: - Download
private func loadFromCache() { private func loadFromCache() async {
PerformanceLogger.shared.track("image.cacheLoad") PerformanceLogger.shared.track("image.cacheLoad")
// Fast path: NSCache hit (synchronous, sub-microsecond)
if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) { if let cached = AttachmentCache.shared.loadImage(forAttachmentId: attachment.id) {
image = cached 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 return nil
} }
/// Android parity: `base64ToBitmap()` with subsampling to max 4096px.
private func parseImageData(_ data: Data) -> UIImage? { private func parseImageData(_ data: Data) -> UIImage? {
if let str = String(data: data, encoding: .utf8) { if let str = String(data: data, encoding: .utf8) {
if str.hasPrefix("data:"), if str.hasPrefix("data:"),
let commaIndex = str.firstIndex(of: ",") { let commaIndex = str.firstIndex(of: ",") {
let base64Part = String(str[str.index(after: commaIndex)...]) let base64Part = String(str[str.index(after: commaIndex)...])
if let imageData = Data(base64Encoded: base64Part), if let imageData = Data(base64Encoded: base64Part),
let img = UIImage(data: imageData) { let img = AttachmentCache.downsampledImage(from: imageData) {
return img return img
} }
} else if let imageData = Data(base64Encoded: str), } else if let imageData = Data(base64Encoded: str),
let img = UIImage(data: imageData) { let img = AttachmentCache.downsampledImage(from: imageData) {
return img return img
} }
} }
return UIImage(data: data) return AttachmentCache.downsampledImage(from: data)
} }
// MARK: - Preview Parsing // MARK: - Preview Parsing