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

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_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 = "";

View File

@@ -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 (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
/// 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)
}

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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