Фикс: поднятие контента сообщений синхронно с раскрытием клавиатуры
This commit is contained in:
@@ -421,7 +421,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Rosetta/Rosetta.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 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 = "";
|
||||
|
||||
@@ -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<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
|
||||
|
||||
/// 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<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).
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user